6e4f7e6020
- 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>
95 lines
3.1 KiB
PHP
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);
|
|
}
|
|
}
|