From 529ae4aa388e4970d3b8b58f1137a884e9aa8eaa Mon Sep 17 00:00:00 2001 From: root Date: Fri, 2 Oct 2026 19:41:27 +0000 Subject: [PATCH] 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 ). - 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 --- app/Services/Chain/BtcDriver.php | 180 +++++++++++++++++++++++++++-- app/Services/Chain/ChainDriver.php | 2 +- app/Services/Chain/EthDriver.php | 2 +- app/Services/Chain/SolDriver.php | 2 +- app/Services/Chain/TronDriver.php | 2 +- app/Services/TransferService.php | 4 +- 6 files changed, 177 insertions(+), 15 deletions(-) diff --git a/app/Services/Chain/BtcDriver.php b/app/Services/Chain/BtcDriver.php index d9d7d44..8fb030d 100644 --- a/app/Services/Chain/BtcDriver.php +++ b/app/Services/Chain/BtcDriver.php @@ -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]; } - $raw = $this->buildAndSign($selected, $outputs, $derived['private_key']); + 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 $inputs + * @param list $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: + .$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 $inputs + * @param list $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'); diff --git a/app/Services/Chain/ChainDriver.php b/app/Services/Chain/ChainDriver.php index 0db807f..dc26967 100644 --- a/app/Services/Chain/ChainDriver.php +++ b/app/Services/Chain/ChainDriver.php @@ -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 diff --git a/app/Services/Chain/EthDriver.php b/app/Services/Chain/EthDriver.php index 7df30e0..3db5a1b 100644 --- a/app/Services/Chain/EthDriver.php +++ b/app/Services/Chain/EthDriver.php @@ -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'); diff --git a/app/Services/Chain/SolDriver.php b/app/Services/Chain/SolDriver.php index aee347f..7c87c56 100644 --- a/app/Services/Chain/SolDriver.php +++ b/app/Services/Chain/SolDriver.php @@ -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'); } diff --git a/app/Services/Chain/TronDriver.php b/app/Services/Chain/TronDriver.php index c88f7c3..bfa7dfd 100644 --- a/app/Services/Chain/TronDriver.php +++ b/app/Services/Chain/TronDriver.php @@ -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'); diff --git a/app/Services/TransferService.php b/app/Services/TransferService.php index 7bf95d1..9015af9 100644 --- a/app/Services/TransferService.php +++ b/app/Services/TransferService.php @@ -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}"), };