feat(chain): BIP84 derivation + BIP143 SegWit signing for BTC sweeps
BtcDriver::sendNative only supported legacy P2PKH (BIP44) inputs: it derived a P2PKH address from the mnemonic, fetched UTXOs there, and signed with the legacy pre-segwit sighash. Sweeping a bc1q (Native SegWit / BIP84) wallet therefore failed: UTXOs were fetched for the wrong (P2PKH) address, and even if found, the legacy sighash would produce an invalid signature. - ChainDriver::sendNative gains an optional ?string $from param so the driver knows which address it is sweeping (TransferService passes it). - BtcDriver::fromType classifies the from address: P2PKH (1...) and P2WPKH (bc1q v0+20) are spendable; P2SH/P2WSH/P2TR are rejected with explicit errors (Taproot-from needs Schnorr/BIP341, deferred). - sendNative picks BIP44 (m/44'/0'/0'/0/i) for P2PKH and BIP84 (m/84'/0'/0'/0/i) for P2WPKH, derives the key, and asserts the derived address equals the requested from address. - New buildAndSignSegwit implements BIP143 SIGHASH_ALL for P2WPKH (hashPrevouts/hashSequence/hashOutputs, per-input scriptCode 1976a914<20>88ac + amount), emits the segwit serialization (marker 0x00 / flag 0x01, empty scriptSig, witness <sig> <pubkey>). - estimateFee gains a $segwit flag using P2WPKH vsize (11 + 68*in + 43*out) so fee math is correct for segwit sweeps. - Legacy P2PKH path (buildAndSign) is unchanged; from=null keeps the original behaviour. Verified locally: BIP84 index 0 of the standard test mnemonic derives the canonical bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu; BIP143 sighash cross-checks against an independent implementation; the produced witness signature verifies (EC) over that sighash; tx structure parses (marker/flag/empty scriptSig/2-item witness) and txid is well-formed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,14 +33,31 @@ class BtcDriver implements ChainDriver
|
||||
return BtcAddress::p2wpkhFromCompressedPublicKey($compressed);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
throw new RuntimeException('Invalid BTC address');
|
||||
}
|
||||
|
||||
$derived = Bip44::derive($mnemonic, $this->path($index));
|
||||
$from = BtcAddress::fromPrivateKey($derived['private_key']);
|
||||
// Resolve the from-address type. Default to legacy P2PKH (BIP44) when no
|
||||
// from address is supplied, preserving the original behaviour.
|
||||
$fromType = $from === null ? 'p2pkh' : $this->fromType($from);
|
||||
$segwit = $fromType === 'p2wpkh';
|
||||
|
||||
$derived = Bip44::derive(
|
||||
$mnemonic,
|
||||
$segwit ? $this->pathBip84($index) : $this->path($index),
|
||||
);
|
||||
$compressed = BtcAddress::compressedPublicKey($derived['private_key']);
|
||||
$derivedFrom = $segwit
|
||||
? BtcAddress::p2wpkhFromCompressedPublicKey($compressed)
|
||||
: BtcAddress::p2pkhFromCompressedPublicKey($compressed);
|
||||
|
||||
if ($from !== null && $from !== $derivedFrom) {
|
||||
throw new RuntimeException('BTC from address does not match derived key');
|
||||
}
|
||||
$from = $derivedFrom;
|
||||
|
||||
$amountSats = $this->toSats($amount);
|
||||
|
||||
$utxos = $this->fetchUtxos($from);
|
||||
@@ -57,17 +74,17 @@ class BtcDriver implements ChainDriver
|
||||
foreach ($utxos as $utxo) {
|
||||
$selected[] = $utxo;
|
||||
$totalIn = bcadd($totalIn, (string) $utxo['value'], 0);
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate);
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate, $segwit);
|
||||
if (bccomp($totalIn, bcadd($target, (string) $fee, 0), 0) >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate);
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate, $segwit);
|
||||
$needed = bcadd($target, (string) $fee, 0);
|
||||
if (bccomp($totalIn, $needed, 0) < 0) {
|
||||
// Try with single output (no change) — dust change becomes fee.
|
||||
$fee1 = $this->estimateFee(count($selected), 1, $feeRate);
|
||||
$fee1 = $this->estimateFee(count($selected), 1, $feeRate, $segwit);
|
||||
$needed1 = bcadd($target, (string) $fee1, 0);
|
||||
if (bccomp($totalIn, $needed1, 0) < 0) {
|
||||
throw new RuntimeException('Insufficient BTC balance for amount+fee');
|
||||
@@ -90,7 +107,13 @@ class BtcDriver implements ChainDriver
|
||||
$outputs[] = ['script' => $changeScript, 'value' => $change];
|
||||
}
|
||||
|
||||
if ($segwit) {
|
||||
$keyhash = bin2hex(hash('ripemd160', hash('sha256', hex2bin($compressed), true), true));
|
||||
$raw = $this->buildAndSignSegwit($selected, $outputs, $derived['private_key'], $keyhash);
|
||||
} else {
|
||||
$raw = $this->buildAndSign($selected, $outputs, $derived['private_key']);
|
||||
}
|
||||
|
||||
$txid = $this->broadcast($raw);
|
||||
if ($txid === '') {
|
||||
throw new RuntimeException('BTC broadcast failed');
|
||||
@@ -99,6 +122,40 @@ class BtcDriver implements ChainDriver
|
||||
return $txid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a BTC from-address for spending. Only single-key P2PKH and
|
||||
* P2WPKH are spendable here; P2SH/P2WSH/P2TR are rejected explicitly.
|
||||
*
|
||||
* @return string 'p2pkh' | 'p2wpkh'
|
||||
*/
|
||||
private function fromType(string $from): string
|
||||
{
|
||||
$from = trim($from);
|
||||
if (preg_match('/^bc1/i', $from)) {
|
||||
$d = BtcAddress::decodeBech32($from);
|
||||
if ($d['version'] === 0 && strlen($d['program']) === 20) {
|
||||
return 'p2wpkh';
|
||||
}
|
||||
if ($d['version'] === 1 && strlen($d['program']) === 32) {
|
||||
throw new RuntimeException('Spending from Taproot (P2TR) is not supported yet');
|
||||
}
|
||||
if ($d['version'] === 0 && strlen($d['program']) === 32) {
|
||||
throw new RuntimeException('Spending from P2WSH is not supported');
|
||||
}
|
||||
throw new RuntimeException('Unsupported SegWit from address');
|
||||
}
|
||||
|
||||
$hex = TronAddress::base58CheckToHex($from);
|
||||
$ver = substr($hex, 0, 2);
|
||||
if ($ver === '00') {
|
||||
return 'p2pkh';
|
||||
}
|
||||
if ($ver === '05') {
|
||||
throw new RuntimeException('Spending from P2SH is not supported');
|
||||
}
|
||||
throw new RuntimeException('Unsupported BTC from address');
|
||||
}
|
||||
|
||||
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
|
||||
{
|
||||
throw new RuntimeException('BTC does not support token transfers');
|
||||
@@ -286,10 +343,13 @@ class BtcDriver implements ChainDriver
|
||||
return 10;
|
||||
}
|
||||
|
||||
private function estimateFee(int $inputs, int $outputs, int $satPerVbyte): int
|
||||
private function estimateFee(int $inputs, int $outputs, int $satPerVbyte, bool $segwit = false): int
|
||||
{
|
||||
// Legacy P2PKH approx: 10 + 148*in + 34*out
|
||||
$vsize = 10 + (148 * $inputs) + (34 * $outputs);
|
||||
// P2WPKH approx (vsize): 11 + 68*in + 43*out (43 covers P2TR outputs; overestimates slightly, safe)
|
||||
$vsize = $segwit
|
||||
? 11 + (68 * $inputs) + (43 * $outputs)
|
||||
: 10 + (148 * $inputs) + (34 * $outputs);
|
||||
|
||||
return max(1, $vsize * max(1, $satPerVbyte));
|
||||
}
|
||||
@@ -352,6 +412,108 @@ class BtcDriver implements ChainDriver
|
||||
return bin2hex($version.$vinCount.$signedVins.$voutCount.$voutPayload.$locktime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and sign a SegWit transaction spending P2WPKH inputs (BIP143).
|
||||
*
|
||||
* @param list<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $outputs
|
||||
* @param string $privateKeyHex hex private key for the P2WPKH keypair
|
||||
* @param string $keyhashHex 20-byte hash160 of the compressed pubkey (hex)
|
||||
*/
|
||||
private function buildAndSignSegwit(array $inputs, array $outputs, string $privateKeyHex, string $keyhashHex): string
|
||||
{
|
||||
$version = $this->u32le(1);
|
||||
$locktime = $this->u32le(0);
|
||||
$marker = "\x00";
|
||||
$flag = "\x01";
|
||||
|
||||
$voutCount = $this->varInt(count($outputs));
|
||||
$voutPayload = '';
|
||||
foreach ($outputs as $out) {
|
||||
$voutPayload .= $this->u64le($out['value']);
|
||||
$script = hex2bin($out['script']);
|
||||
if ($script === false) {
|
||||
throw new RuntimeException('Invalid output script');
|
||||
}
|
||||
$voutPayload .= $this->varInt(strlen($script)).$script;
|
||||
}
|
||||
|
||||
$pub = hex2bin(BtcAddress::compressedPublicKey($privateKeyHex));
|
||||
if ($pub === false) {
|
||||
throw new RuntimeException('Invalid public key');
|
||||
}
|
||||
|
||||
$witnesses = '';
|
||||
$vinPayload = '';
|
||||
foreach ($inputs as $i => $in) {
|
||||
$hash = $this->segwitSighashAll($inputs, $outputs, $i, $keyhashHex);
|
||||
|
||||
$der = $this->signDer($privateKeyHex, $hash)."\x01"; // SIGHASH_ALL
|
||||
$witness = $this->varInt(2) // 2 stack items: <sig> <pubkey>
|
||||
.$this->pushData($der)
|
||||
.$this->pushData($pub);
|
||||
$witnesses .= $witness;
|
||||
|
||||
$vinPayload .= $this->outpoint($in['txid'], $in['vout']);
|
||||
$vinPayload .= $this->varInt(0); // empty scriptSig for native SegWit
|
||||
$vinPayload .= $this->u32le(0xffffffff);
|
||||
}
|
||||
|
||||
$vinCount = $this->varInt(count($inputs));
|
||||
|
||||
return bin2hex($version.$marker.$flag.$vinCount.$vinPayload.$voutCount.$voutPayload.$witnesses.$locktime);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP143 SIGHASH_ALL sighash for a P2WPKH input (32-byte raw binary).
|
||||
*
|
||||
* @param list<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $outputs
|
||||
*/
|
||||
private function segwitSighashAll(array $inputs, array $outputs, int $inputIndex, string $keyhashHex): string
|
||||
{
|
||||
$version = $this->u32le(1);
|
||||
$locktime = $this->u32le(0);
|
||||
|
||||
$prevouts = '';
|
||||
$sequences = '';
|
||||
foreach ($inputs as $in) {
|
||||
$prevouts .= $this->outpoint($in['txid'], $in['vout']);
|
||||
$sequences .= $this->u32le(0xffffffff);
|
||||
}
|
||||
$hashPrevouts = hash('sha256', hash('sha256', $prevouts, true), true);
|
||||
$hashSequence = hash('sha256', hash('sha256', $sequences, true), true);
|
||||
|
||||
$hashOutputsData = '';
|
||||
foreach ($outputs as $out) {
|
||||
$script = hex2bin($out['script']);
|
||||
if ($script === false) {
|
||||
throw new RuntimeException('Invalid output script');
|
||||
}
|
||||
$hashOutputsData .= $this->u64le($out['value']).$this->varInt(strlen($script)).$script;
|
||||
}
|
||||
$hashOutputs = hash('sha256', hash('sha256', $hashOutputsData, true), true);
|
||||
|
||||
$scriptCode = hex2bin('1976a914'.$keyhashHex.'88ac');
|
||||
if ($scriptCode === false) {
|
||||
throw new RuntimeException('Invalid P2WPKH scriptCode');
|
||||
}
|
||||
$in = $inputs[$inputIndex];
|
||||
|
||||
$preimage = $version
|
||||
.$hashPrevouts
|
||||
.$hashSequence
|
||||
.$this->outpoint($in['txid'], $in['vout'])
|
||||
.$this->varInt(strlen($scriptCode)).$scriptCode
|
||||
.$this->u64le((string) $in['value'])
|
||||
.$this->u32le(0xffffffff)
|
||||
.$hashOutputs
|
||||
.$locktime
|
||||
.$this->u32le(1); // SIGHASH_ALL
|
||||
|
||||
return hash('sha256', hash('sha256', $preimage, true), true);
|
||||
}
|
||||
|
||||
private function signDer(string $privateKey, string $hash32): string
|
||||
{
|
||||
$ec = new EC('secp256k1');
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ChainDriver
|
||||
/**
|
||||
* @return string txid
|
||||
*/
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string;
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string;
|
||||
|
||||
/**
|
||||
* @return string txid
|
||||
|
||||
@@ -20,7 +20,7 @@ class EthDriver implements ChainDriver
|
||||
return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
throw new RuntimeException('Invalid ETH address');
|
||||
|
||||
@@ -24,7 +24,7 @@ class SolDriver implements ChainDriver
|
||||
return SolAddress::fromMnemonic($mnemonic, $index);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
throw new RuntimeException('SOL native transfer not supported');
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class TronDriver implements ChainDriver
|
||||
return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
|
||||
@@ -105,7 +105,7 @@ class TransferService
|
||||
}
|
||||
|
||||
$txid = match ($asset) {
|
||||
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
||||
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
|
||||
'USDT' => $driver->sendToken(
|
||||
$mnemonic,
|
||||
$index,
|
||||
@@ -113,7 +113,7 @@ class TransferService
|
||||
$amount,
|
||||
$this->usdtContract($chain),
|
||||
),
|
||||
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
||||
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
|
||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user