dff472180c
Bug1: DarkSwordIngestAdapter::harvestAddresses 对加密 keystore 文本跑地址正则, 会把 xpub 子串/hex IV 误识别为地址。新增 EthAddress/TronAddress/BtcAddress isValid 校验,拒绝假地址入库。 Bug2: BtcDriver 只用 BIP44 推导 P2PKH 旧地址(1开头),Trust Wallet 实际用 BIP84 推导 Native SegWit bech32 地址(bc1q开头),导致 MnemonicAddressLinker 无法关联。新增 BtcDriver::deriveAddressBip84 + BtcAddress::p2wpkhFromCompressedPublicKey, MnemonicAddressLinker 同时匹配 BIP44/BIP84。 Co-authored-by: Cursor <cursoragent@cursor.com>
497 lines
15 KiB
PHP
497 lines
15 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
|
|
{
|
|
if (! $this->isValidAddress($to)) {
|
|
throw new RuntimeException('Invalid BTC address');
|
|
}
|
|
|
|
$derived = Bip44::derive($mnemonic, $this->path($index));
|
|
$from = BtcAddress::fromPrivateKey($derived['private_key']);
|
|
$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);
|
|
if (bccomp($totalIn, bcadd($target, (string) $fee, 0), 0) >= 0) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
$fee = $this->estimateFee(count($selected), 2, $feeRate);
|
|
$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);
|
|
$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];
|
|
}
|
|
|
|
$raw = $this->buildAndSign($selected, $outputs, $derived['private_key']);
|
|
$txid = $this->broadcast($raw);
|
|
if ($txid === '') {
|
|
throw new RuntimeException('BTC broadcast failed');
|
|
}
|
|
|
|
return $txid;
|
|
}
|
|
|
|
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): int
|
|
{
|
|
// Legacy P2PKH approx: 10 + 148*in + 34*out
|
|
$vsize = 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);
|
|
}
|
|
|
|
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 Http::timeout(30)->acceptJson();
|
|
}
|
|
}
|