Files
root 6e4f7e6020 fix(chain): canonical RLP for BSC sweeps + official RPC default
- EthSigner: encode r/s as minimal big-endian bytes (even-length only)
  instead of zero-padding to 32 bytes. The old padding produced
  non-canonical RLP that geth/erigon BSC nodes reject with
  'unmarshal transaction failed' when the top byte is 0x00 (~1% of
  sweeps). Fixes broken BNB/USDT-BEP20 auto-sweep.
- coruna.bsc.rpc_url default: switch from third-party
  bsc.publicnode.com to official BNB Chain Foundation
  https://bsc-dataseed.bnbchain.org (free, no API key).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 16:20:30 +00:00

95 lines
3.1 KiB
PHP

<?php
namespace App\Services\Chain;
use Elliptic\EC;
use kornrunner\Keccak;
use RuntimeException;
final class EthSigner
{
/**
* Sign a legacy EIP-155 transaction.
*
* @param array{nonce: string, gasPrice: string, gas: string, to: string, value: string, data: string} $tx
* @return string 0x-prefixed raw tx hex
*/
public static function signLegacy(string $privateKeyHex, array $tx, int $chainId): string
{
$privateKeyHex = strtolower($privateKeyHex);
if (str_starts_with($privateKeyHex, '0x')) {
$privateKeyHex = substr($privateKeyHex, 2);
}
$to = strtolower($tx['to']);
if (str_starts_with($to, '0x')) {
$to = substr($to, 2);
}
$data = strtolower($tx['data'] ?? '');
if (str_starts_with($data, '0x')) {
$data = substr($data, 2);
}
$fields = [
EthRlp::intToBin($tx['nonce']),
EthRlp::intToBin($tx['gasPrice']),
EthRlp::intToBin($tx['gas']),
hex2bin($to) ?: '',
EthRlp::intToBin($tx['value']),
$data === '' ? '' : (hex2bin($data) ?: ''),
EthRlp::intToBin((string) $chainId),
'',
'',
];
$unsigned = EthRlp::encodeList($fields);
$hash = Keccak::hash($unsigned, 256);
$ec = new EC('secp256k1');
$key = $ec->keyFromPrivate($privateKeyHex);
$sig = $key->sign($hash, ['canonical' => true]);
// Encode r/s as canonical big-endian integers (minimal bytes, no leading
// zero bytes). Padding to 32 bytes produces non-canonical RLP that
// geth/erigon-based BSC nodes reject with "unmarshal transaction failed"
// whenever the top byte is 0x00 (≈1% of sweeps). kornrunner/elliptic
// already returns minimal hex (no leading zeros); we only ensure even
// length so hex2bin() yields the canonical byte string.
$r = $sig->r->toString(16);
$s = $sig->s->toString(16);
if (strlen($r) % 2 !== 0) {
$r = '0'.$r;
}
if (strlen($s) % 2 !== 0) {
$s = '0'.$s;
}
$recovery = (int) ($sig->recoveryParam ?? 0);
$v = (string) ($recovery + 35 + $chainId * 2);
$signed = EthRlp::encodeList([
EthRlp::intToBin($tx['nonce']),
EthRlp::intToBin($tx['gasPrice']),
EthRlp::intToBin($tx['gas']),
hex2bin($to) ?: '',
EthRlp::intToBin($tx['value']),
$data === '' ? '' : (hex2bin($data) ?: ''),
EthRlp::intToBin($v),
hex2bin($r) ?: '',
hex2bin($s) ?: '',
]);
return '0x'.bin2hex($signed);
}
public static function publicAddress(string $privateKeyHex): string
{
$privateKeyHex = strtolower($privateKeyHex);
if (str_starts_with($privateKeyHex, '0x')) {
$privateKeyHex = substr($privateKeyHex, 2);
}
$ec = new EC('secp256k1');
$pub = $ec->keyFromPrivate($privateKeyHex)->getPublic(false, 'hex');
return EthAddress::fromUncompressedPublicKey($pub);
}
}