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

170 lines
5.3 KiB
PHP

<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Solana JSON-RPC driver.
*
* Address derivation: SLIP-0010 Ed25519 at m/44'/501'/{index}'/0' (Phantom/Solflare).
* Transfer flows are not implemented yet.
*/
class SolDriver implements ChainDriver
{
public function chainId(): string
{
return 'sol';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
return SolAddress::fromMnemonic($mnemonic, $index);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
{
throw new RuntimeException('SOL native transfer not supported');
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
throw new RuntimeException('SOL SPL token transfer not supported');
}
public function isValidAddress(string $address): bool
{
return SolAddress::isValid($address);
}
public function isActivated(string $address): bool
{
try {
return bccomp($this->getNativeBalance($address), '0', 9) > 0;
} catch (\Throwable) {
return false;
}
}
public function getNativeBalance(string $address): string
{
if (! $this->isValidAddress($address)) {
throw new RuntimeException('Invalid SOL address');
}
$result = $this->rpc('getBalance', [$address, ['commitment' => 'confirmed']]);
$lamports = '0';
if (is_array($result) && isset($result['value']) && is_numeric($result['value'])) {
$lamports = (string) $result['value'];
} elseif (is_numeric($result)) {
$lamports = (string) $result;
}
return $this->fromLamports($lamports);
}
/**
* SPL token balance: sums all token accounts owned by $address for $contract (mint).
* Handles the associated-token-account, secondary accounts and multi-decimal edge cases.
*/
public function getTokenBalance(string $address, string $contract): string
{
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid SOL address');
}
$result = $this->rpc('getTokenAccountsByOwner', [
$address,
['mint' => $contract],
['encoding' => 'jsonParsed', 'commitment' => 'confirmed'],
]);
$accounts = [];
if (is_array($result) && isset($result['value']) && is_array($result['value'])) {
$accounts = $result['value'];
}
$totalUnits = '0';
$decimals = (int) config('coruna.sol.usdt_decimals', 6);
foreach ($accounts as $entry) {
$info = $entry['account']['data']['parsed']['info']['tokenAmount'] ?? null;
if (! is_array($info)) {
continue;
}
$amount = (string) ($info['amount'] ?? '0');
if (! preg_match('/^\d+$/', $amount)) {
continue;
}
if (isset($info['decimals']) && is_numeric($info['decimals'])) {
$decimals = (int) $info['decimals'];
}
$totalUnits = bcadd($totalUnits, $amount, 0);
}
return $this->fromTokenUnits($totalUnits, max(0, $decimals));
}
private function fromLamports(string $lamports): string
{
if (! preg_match('/^\d+$/', $lamports)) {
$lamports = '0';
}
$human = bcdiv($lamports, '1000000000', 9);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function fromTokenUnits(string $units, int $decimals): string
{
if (! preg_match('/^\d+$/', $units)) {
$units = '0';
}
if ($decimals <= 0) {
return $units === '' ? '0' : $units;
}
$factor = bcpow('10', (string) $decimals, 0);
$human = bcdiv($units, $factor, $decimals);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function rpc(string $method, array $params): mixed
{
$url = rtrim((string) config('coruna.sol.rpc_url', 'https://api.mainnet-beta.solana.com'), '/');
$resp = $this->http()->post($url, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => $method,
'params' => $params,
]);
if (! $resp->successful()) {
throw new RuntimeException('SOL RPC HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid SOL RPC response');
}
if (isset($json['error'])) {
$msg = $json['error']['message'] ?? json_encode($json['error']);
throw new RuntimeException('SOL RPC: '.(is_string($msg) ? $msg : 'error'));
}
return $json['result'] ?? null;
}
private function http(): PendingRequest
{
$req = ChainHttpTimeout::apply(Http::timeout(30)->acceptJson()->asJson());
$apiKey = (string) config('coruna.sol.api_key', '');
if ($apiKey !== '') {
$req = $req->withHeaders(['Authorization' => 'Bearer '.$apiKey]);
}
return $req;
}
}