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 $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 */ 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 $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 */ 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 $inputs * @param list $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(); } }