Files
coruna-lab/app/Services/Chain/EthDriver.php
T
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

286 lines
9.4 KiB
PHP

<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class EthDriver implements ChainDriver
{
public function chainId(): string
{
return 'eth';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
$derived = Bip44::derive($mnemonic, $this->path($index));
return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
}
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');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$wei = $this->toWei($amount);
return $this->sendLegacy($derived['private_key'], $from, $to, $wei, '0x');
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
if (! $this->isValidAddress($to) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid ETH address');
}
$derived = Bip44::derive($mnemonic, $this->path($index));
$from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
$units = $this->toTokenUnits($amount, $this->tokenDecimals($contract));
// transfer(address,uint256) selector = a9059cbb
$data = '0xa9059cbb'.EthAddress::toWord($to).str_pad(gmp_strval(gmp_init($units, 10), 16), 64, '0', STR_PAD_LEFT);
return $this->sendLegacy($derived['private_key'], $from, $contract, '0', $data);
}
public function isValidAddress(string $address): bool
{
return EthAddress::isValid($address);
}
public function isActivated(string $address): bool
{
if (! $this->isValidAddress($address)) {
return false;
}
try {
$hex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($address), 'latest']);
if (is_string($hex) && bccomp($this->hexToDec($hex), '0', 0) > 0) {
return true;
}
} catch (\Throwable $e) {
if (ChainHttpTimeout::active()) {
throw $e;
}
// fall through to balances
}
try {
if (bccomp($this->getNativeBalance($address), '0', 18) > 0) {
return true;
}
} catch (\Throwable $e) {
if (ChainHttpTimeout::active()) {
throw $e;
}
// fall through to token
}
$contract = trim((string) config($this->configPrefix().'.usdt_contract', ''));
if ($contract !== '') {
try {
if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) {
return true;
}
} catch (\Throwable $e) {
if (ChainHttpTimeout::active()) {
throw $e;
}
return false;
}
}
return false;
}
public function getNativeBalance(string $address): string
{
if (! $this->isValidAddress($address)) {
throw new RuntimeException('Invalid ETH address');
}
$hex = $this->rpc('eth_getBalance', [EthAddress::normalize($address), 'latest']);
if (! is_string($hex)) {
return '0';
}
return $this->fromWei($this->hexToDec($hex));
}
public function getTokenBalance(string $address, string $contract): string
{
if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) {
throw new RuntimeException('Invalid ETH address');
}
// balanceOf(address)
$data = '0x70a08231'.EthAddress::toWord($address);
$hex = $this->rpc('eth_call', [[
'to' => EthAddress::normalize($contract),
'data' => $data,
], 'latest']);
if (! is_string($hex) || $hex === '' || $hex === '0x') {
return '0';
}
return $this->fromTokenUnits($this->hexToDec($hex), $this->tokenDecimals($contract));
}
private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string
{
$chainId = (int) config($this->configPrefix().'.chain_id', 1);
$nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']);
$gasPriceHex = $this->rpc('eth_gasPrice', []);
if (! is_string($nonceHex) || ! is_string($gasPriceHex)) {
throw new RuntimeException('Failed to fetch nonce/gasPrice');
}
$gasLimit = trim((string) config($this->configPrefix().'.gas_limit', ''));
if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) {
$gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000';
}
$tx = [
'nonce' => $this->hexToDec($nonceHex),
'gasPrice' => $this->hexToDec($gasPriceHex),
'gas' => $gasLimit,
'to' => EthAddress::normalize($to),
'value' => $valueWei,
'data' => $data === '' ? '0x' : $data,
];
$raw = EthSigner::signLegacy($privateKey, $tx, $chainId);
$txid = $this->rpc('eth_sendRawTransaction', [$raw]);
if (! is_string($txid) || $txid === '') {
throw new RuntimeException('eth_sendRawTransaction failed');
}
return $txid;
}
private function path(int $index): string
{
return "m/44'/60'/0'/0/{$index}";
}
private function toWei(string $amount): string
{
if (! preg_match('/^\d+(\.\d{1,18})?$/', $amount)) {
throw new RuntimeException('Invalid ETH amount');
}
[$whole, $frac] = array_pad(explode('.', $amount, 2), 2, '');
$frac = str_pad(substr($frac, 0, 18), 18, '0', STR_PAD_RIGHT);
$wei = ltrim($whole.$frac, '0');
$wei = $wei === '' ? '0' : $wei;
if (bccomp($wei, '0') <= 0) {
throw new RuntimeException('Amount must be positive');
}
return $wei;
}
private function fromWei(string $wei): string
{
if (! preg_match('/^\d+$/', $wei)) {
$wei = '0';
}
$human = bcdiv($wei, '1000000000000000000', 18);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
protected function configPrefix(): string
{
return 'coruna.eth';
}
private function tokenDecimals(string $contract): int
{
$normalized = EthAddress::normalize($contract);
$usdt = EthAddress::normalize((string) config($this->configPrefix().'.usdt_contract', ''));
$decimals = (int) config($this->configPrefix().'.usdt_decimals', 6);
if ($normalized !== '' && $usdt !== '' && $normalized === $usdt && $decimals > 0) {
return $decimals;
}
return max(1, $decimals > 0 ? $decimals : 6);
}
private function toTokenUnits(string $amount, int $decimals): string
{
$decimals = max(1, $decimals);
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) {
throw new RuntimeException('Invalid token amount');
}
$factor = bcpow('10', (string) $decimals, 0);
$units = bcmul($amount, $factor, 0);
if (bccomp($units, '0') <= 0) {
throw new RuntimeException('Amount must be positive');
}
return $units;
}
private function fromTokenUnits(string $units, int $decimals): string
{
$decimals = max(0, $decimals);
if (! preg_match('/^\d+$/', $units)) {
$units = '0';
}
$factor = bcpow('10', (string) $decimals, 0);
$human = bcdiv($units, $factor, $decimals);
$human = rtrim(rtrim($human, '0'), '.');
return $human === '' ? '0' : $human;
}
private function hexToDec(string $hex): string
{
$hex = strtolower($hex);
if (str_starts_with($hex, '0x')) {
$hex = substr($hex, 2);
}
if ($hex === '' || $hex === '0') {
return '0';
}
return gmp_strval(gmp_init($hex, 16), 10);
}
private function rpc(string $method, array $params): mixed
{
$url = rtrim((string) config($this->configPrefix().'.rpc_url', 'https://ethereum.publicnode.com'), '/');
$resp = $this->http()->post($url, [
'jsonrpc' => '2.0',
'id' => 1,
'method' => $method,
'params' => $params,
]);
if (! $resp->successful()) {
throw new RuntimeException(strtoupper($this->chainId()).' RPC HTTP '.$resp->status());
}
$json = $resp->json();
if (! is_array($json)) {
throw new RuntimeException('Invalid '.strtoupper($this->chainId()).' RPC response');
}
if (isset($json['error'])) {
$msg = $json['error']['message'] ?? json_encode($json['error']);
throw new RuntimeException(strtoupper($this->chainId()).' RPC: '.(is_string($msg) ? $msg : 'error'));
}
return $json['result'] ?? null;
}
private function http(): PendingRequest
{
return ChainHttpTimeout::apply(Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson());
}
}