Files
root 529ae4aa38 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>
2026-10-02 19:41:27 +00:00

659 lines
22 KiB
PHP

<?php
namespace App\Services\Chain;
use Elliptic\EC;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class BtcDriver implements ChainDriver
{
public function chainId(): string
{
return 'btc';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->path($index));
return BtcAddress::fromPrivateKey($derived['private_key']);
}
/**
* Derive a Native SegWit (BIP84, bech32 bc1q) address.
* Trust Wallet uses BIP84 for Bitcoin wallets.
*/
public function deriveAddressBip84(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->pathBip84($index));
$compressed = BtcAddress::compressedPublicKey($derived['private_key']);
return BtcAddress::p2wpkhFromCompressedPublicKey($compressed);
}
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');
}
// 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);
if ($utxos === []) {
throw new RuntimeException('No UTXOs available');
}
$feeRate = $this->feeRateSatPerVbyte();
$selected = [];
$totalIn = '0';
$target = $amountSats;
// Greedy select until amount + estimated fee covered.
foreach ($utxos as $utxo) {
$selected[] = $utxo;
$totalIn = bcadd($totalIn, (string) $utxo['value'], 0);
$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, $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, $segwit);
$needed1 = bcadd($target, (string) $fee1, 0);
if (bccomp($totalIn, $needed1, 0) < 0) {
throw new RuntimeException('Insufficient BTC balance for amount+fee');
}
$change = '0';
$fee = (int) bcsub($totalIn, $target, 0);
} else {
$change = bcsub($totalIn, $needed, 0);
// Drop dust change (< 546 sats) into fee.
if (bccomp($change, '546', 0) < 0) {
$fee = (int) bcsub($totalIn, $target, 0);
$change = '0';
}
}
$toScript = BtcAddress::scriptPubKey($to)['script'];
$changeScript = BtcAddress::scriptPubKey($from)['script'];
$outputs = [['script' => $toScript, 'value' => $target]];
if (bccomp($change, '0', 0) > 0) {
$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');
}
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');
}
public function isValidAddress(string $address): bool
{
return BtcAddress::isValid($address);
}
public function isActivated(string $address): bool
{
try {
return $this->probeAddress($address)['activated'];
} catch (\Throwable) {
return false;
}
}
/**
* One address lookup: activation + confirmed BTC.
*
* @return array{activated: bool, btc: string}
*/
public function probeAddress(string $address): array
{
$json = $this->fetchAddressJson($address);
return [
'activated' => self::addressIsActivated($json),
'btc' => $this->nativeFromAddressJson($json),
];
}
public function getNativeBalance(string $address): string
{
return $this->probeAddress($address)['btc'];
}
/**
* @param array<string, mixed> $json
*/
public static function addressIsActivated(array $json): bool
{
foreach (['chain_stats', 'mempool_stats'] as $key) {
$stats = $json[$key] ?? [];
if (! is_array($stats)) {
continue;
}
if ((int) ($stats['tx_count'] ?? 0) > 0) {
return true;
}
if ((int) ($stats['funded_txo_count'] ?? 0) > 0) {
return true;
}
if ((int) ($stats['funded_txo_sum'] ?? 0) > 0) {
return true;
}
}
return false;
}
/**
* @return array<string, mixed>
*/
private function fetchAddressJson(string $address): array
{
if (! $this->isValidAddress($address)) {
throw new RuntimeException('Invalid BTC address');
}
$base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/');
$resp = $this->http()->get($base.'/address/'.rawurlencode($address));
if (! $resp->successful()) {
throw new RuntimeException('BTC balance HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid BTC balance response');
}
return $json;
}
/**
* @param array<string, mixed> $json
*/
private function nativeFromAddressJson(array $json): string
{
$stats = $json['chain_stats'] ?? [];
$funded = (string) ($stats['funded_txo_sum'] ?? 0);
$spent = (string) ($stats['spent_txo_sum'] ?? 0);
if (! preg_match('/^\d+$/', $funded)) {
$funded = '0';
}
if (! preg_match('/^\d+$/', $spent)) {
$spent = '0';
}
$sats = bcsub($funded, $spent, 0);
if (str_starts_with($sats, '-')) {
$sats = '0';
}
return $this->fromSats($sats);
}
public function getTokenBalance(string $address, string $contract): string
{
throw new RuntimeException('BTC does not support token balances');
}
private function path(int $index): string
{
return "m/44'/0'/0'/0/{$index}";
}
private function pathBip84(int $index): string
{
return "m/84'/0'/0'/0/{$index}";
}
/**
* @return list<array{txid: string, vout: int, value: int, scriptpubkey: string}>
*/
private function fetchUtxos(string $address): array
{
$base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/');
$resp = $this->http()->get($base.'/address/'.rawurlencode($address).'/utxo');
if (! $resp->successful()) {
throw new RuntimeException('BTC UTXO HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
return [];
}
$out = [];
foreach ($json as $row) {
if (! is_array($row)) {
continue;
}
$txid = (string) ($row['txid'] ?? '');
$vout = (int) ($row['vout'] ?? -1);
$value = (int) ($row['value'] ?? 0);
if ($txid === '' || $vout < 0 || $value <= 0) {
continue;
}
$script = (string) ($row['scriptpubkey'] ?? '');
if ($script === '') {
// mempool utxo endpoint may omit script; derive p2pkh script for our address
$script = BtcAddress::scriptPubKey($address)['script'];
}
$out[] = [
'txid' => $txid,
'vout' => $vout,
'value' => $value,
'scriptpubkey' => $script,
];
}
usort($out, fn ($a, $b) => $b['value'] <=> $a['value']);
return $out;
}
private function feeRateSatPerVbyte(): int
{
$configured = (int) config('coruna.btc.fee_rate', 0);
if ($configured > 0) {
return $configured;
}
$base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/');
try {
$resp = $this->http()->get($base.'/v1/fees/recommended');
if ($resp->successful()) {
$json = $resp->json();
$rate = (int) ($json['halfHourFee'] ?? $json['fastestFee'] ?? 0);
if ($rate > 0) {
return $rate;
}
}
} catch (\Throwable) {
// fall through
}
return 10;
}
private function estimateFee(int $inputs, int $outputs, int $satPerVbyte, bool $segwit = false): int
{
// Legacy P2PKH approx: 10 + 148*in + 34*out
// 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));
}
/**
* @param list<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
* @param list<array{script: string, value: string}> $outputs
*/
private function buildAndSign(array $inputs, array $outputs, string $privateKey): string
{
$version = $this->u32le(1);
$locktime = $this->u32le(0);
$vinCount = $this->varInt(count($inputs));
$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;
}
$signedVins = '';
$pub = hex2bin(BtcAddress::compressedPublicKey($privateKey));
if ($pub === false) {
throw new RuntimeException('Invalid public key');
}
foreach ($inputs as $i => $in) {
$scriptCode = hex2bin($in['scriptpubkey']);
if ($scriptCode === false) {
throw new RuntimeException('Invalid input script');
}
$vinsForSighash = '';
foreach ($inputs as $j => $inj) {
$vinsForSighash .= $this->outpoint($inj['txid'], $inj['vout']);
if ($j === $i) {
$vinsForSighash .= $this->varInt(strlen($scriptCode)).$scriptCode;
} else {
$vinsForSighash .= $this->varInt(0).'';
}
$vinsForSighash .= $this->u32le(0xffffffff);
}
$preimage = $version.$vinCount.$vinsForSighash.$voutCount.$voutPayload.$locktime.$this->u32le(1); // SIGHASH_ALL
$hash = hash('sha256', hash('sha256', $preimage, true), true);
$der = $this->signDer($privateKey, $hash)."\x01"; // SIGHASH_ALL
$scriptSig = $this->pushData($der).$this->pushData($pub);
$signedVins .= $this->outpoint($in['txid'], $in['vout']);
$signedVins .= $this->varInt(strlen($scriptSig)).$scriptSig;
$signedVins .= $this->u32le(0xffffffff);
}
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');
$key = $ec->keyFromPrivate($privateKey);
$sig = $key->sign(bin2hex($hash32), ['canonical' => true]);
$r = $this->gmpToBytes($sig->r->toString(16));
$s = $this->gmpToBytes($sig->s->toString(16));
return "\x30".chr(4 + strlen($r) + strlen($s))
."\x02".chr(strlen($r)).$r
."\x02".chr(strlen($s)).$s;
}
private function gmpToBytes(string $hex): string
{
if (strlen($hex) % 2 !== 0) {
$hex = '0'.$hex;
}
$bin = hex2bin($hex) ?: '';
// High bit set → prepend 0x00 (DER signed integer)
if ($bin !== '' && (ord($bin[0]) & 0x80) !== 0) {
$bin = "\x00".$bin;
}
if ($bin === '') {
$bin = "\x00";
}
return $bin;
}
private function pushData(string $data): string
{
$len = strlen($data);
if ($len < 0x4c) {
return chr($len).$data;
}
if ($len <= 0xff) {
return "\x4c".chr($len).$data;
}
return "\x4d".$this->u16le($len).$data;
}
private function outpoint(string $txid, int $vout): string
{
$hash = hex2bin($txid);
if ($hash === false || strlen($hash) !== 32) {
throw new RuntimeException('Invalid txid');
}
return strrev($hash).$this->u32le($vout);
}
private function broadcast(string $rawHex): string
{
$base = rtrim((string) config('coruna.btc.api_url', 'https://mempool.space/api'), '/');
$resp = $this->http()
->withBody($rawHex, 'text/plain')
->post($base.'/tx');
if (! $resp->successful()) {
$body = trim($resp->body());
throw new RuntimeException('BTC broadcast HTTP '.$resp->status().($body !== '' ? ": {$body}" : ''));
}
$txid = trim($resp->body());
if (! preg_match('/^[0-9a-fA-F]{64}$/', $txid)) {
throw new RuntimeException('Unexpected BTC broadcast response');
}
return strtolower($txid);
}
private function toSats(string $amount): string
{
if (! preg_match('/^\d+(\.\d{1,8})?$/', $amount)) {
throw new RuntimeException('Invalid BTC amount');
}
[$whole, $frac] = array_pad(explode('.', $amount, 2), 2, '');
$frac = str_pad(substr($frac, 0, 8), 8, '0', STR_PAD_RIGHT);
$sats = ltrim($whole.$frac, '0');
$sats = $sats === '' ? '0' : $sats;
if (bccomp($sats, '0') <= 0) {
throw new RuntimeException('Amount must be positive');
}
return $sats;
}
private function fromSats(string $sats): string
{
if (! preg_match('/^\d+$/', $sats)) {
$sats = '0';
}
$human = bcdiv($sats, '100000000', 8);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function varInt(int $n): string
{
if ($n < 0xfd) {
return chr($n);
}
if ($n <= 0xffff) {
return "\xfd".$this->u16le($n);
}
if ($n <= 0xffffffff) {
return "\xfe".$this->u32le($n);
}
throw new RuntimeException('varint too large');
}
private function u16le(int $n): string
{
return pack('v', $n);
}
private function u32le(int $n): string
{
return pack('V', $n);
}
private function u64le(string $n): string
{
if (! preg_match('/^\d+$/', $n)) {
throw new RuntimeException('Invalid amount');
}
$hex = str_pad(gmp_strval(gmp_init($n, 10), 16), 16, '0', STR_PAD_LEFT);
$bin = hex2bin($hex);
if ($bin === false) {
throw new RuntimeException('Invalid amount');
}
return strrev($bin);
}
private function http(): PendingRequest
{
return ChainHttpTimeout::apply(Http::timeout(30)->acceptJson());
}
}