feat: new chain
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Elliptic\EC;
|
||||
use RuntimeException;
|
||||
|
||||
final class BtcAddress
|
||||
{
|
||||
public static function fromPrivateKey(string $privateKeyHex): string
|
||||
{
|
||||
$privateKeyHex = strtolower(trim($privateKeyHex));
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$ec = new EC('secp256k1');
|
||||
$compressed = $ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex');
|
||||
|
||||
return self::p2pkhFromCompressedPublicKey($compressed);
|
||||
}
|
||||
|
||||
public static function p2pkhFromCompressedPublicKey(string $compressedHex): string
|
||||
{
|
||||
$compressedHex = strtolower(trim($compressedHex));
|
||||
$pub = hex2bin($compressedHex);
|
||||
if ($pub === false || (strlen($pub) !== 33)) {
|
||||
throw new RuntimeException('Expected compressed secp256k1 public key');
|
||||
}
|
||||
$hash160 = hash('ripemd160', hash('sha256', $pub, true), true);
|
||||
|
||||
return TronAddress::hexToBase58Check('00'.bin2hex($hash160));
|
||||
}
|
||||
|
||||
public static function isValid(string $address): bool
|
||||
{
|
||||
$address = trim($address);
|
||||
if ($address === '') {
|
||||
return false;
|
||||
}
|
||||
if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $address)) {
|
||||
try {
|
||||
self::decodeBech32($address);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (! preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $address)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$hex = TronAddress::base58CheckToHex($address);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
$version = substr($hex, 0, 2);
|
||||
|
||||
return ($version === '00' || $version === '05') && strlen($hex) === 42;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, script: string} script hex
|
||||
*/
|
||||
public static function scriptPubKey(string $address): array
|
||||
{
|
||||
$address = trim($address);
|
||||
if (preg_match('/^bc1/i', $address)) {
|
||||
$decoded = self::decodeBech32($address);
|
||||
$prog = $decoded['program'];
|
||||
$ver = $decoded['version'];
|
||||
if ($ver === 0 && strlen($prog) === 20) {
|
||||
// OP_0 <20>
|
||||
return ['type' => 'p2wpkh', 'script' => '0014'.bin2hex($prog)];
|
||||
}
|
||||
if ($ver === 0 && strlen($prog) === 32) {
|
||||
return ['type' => 'p2wsh', 'script' => '0020'.bin2hex($prog)];
|
||||
}
|
||||
throw new RuntimeException('Unsupported bech32 witness program');
|
||||
}
|
||||
|
||||
$hex = TronAddress::base58CheckToHex($address);
|
||||
$version = substr($hex, 0, 2);
|
||||
$hash = substr($hex, 2);
|
||||
if ($version === '00' && strlen($hash) === 40) {
|
||||
// OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG
|
||||
return ['type' => 'p2pkh', 'script' => '76a914'.$hash.'88ac'];
|
||||
}
|
||||
if ($version === '05' && strlen($hash) === 40) {
|
||||
// OP_HASH160 <20> OP_EQUAL
|
||||
return ['type' => 'p2sh', 'script' => 'a914'.$hash.'87'];
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unsupported BTC address type');
|
||||
}
|
||||
|
||||
public static function hash160Compressed(string $privateKeyHex): string
|
||||
{
|
||||
$privateKeyHex = strtolower(trim($privateKeyHex));
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$ec = new EC('secp256k1');
|
||||
$compressed = hex2bin($ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex'));
|
||||
if ($compressed === false) {
|
||||
throw new RuntimeException('Invalid public key');
|
||||
}
|
||||
|
||||
return hash('ripemd160', hash('sha256', $compressed, true), true);
|
||||
}
|
||||
|
||||
public static function compressedPublicKey(string $privateKeyHex): string
|
||||
{
|
||||
$privateKeyHex = strtolower(trim($privateKeyHex));
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$ec = new EC('secp256k1');
|
||||
|
||||
return $ec->keyFromPrivate($privateKeyHex)->getPublic(true, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version: int, program: string}
|
||||
*/
|
||||
public static function decodeBech32(string $address): array
|
||||
{
|
||||
$address = strtolower(trim($address));
|
||||
$pos = strrpos($address, '1');
|
||||
if ($pos === false || $pos < 1) {
|
||||
throw new RuntimeException('Invalid bech32');
|
||||
}
|
||||
$hrp = substr($address, 0, $pos);
|
||||
if ($hrp !== 'bc' && $hrp !== 'tb') {
|
||||
throw new RuntimeException('Unsupported bech32 hrp');
|
||||
}
|
||||
$dataPart = substr($address, $pos + 1);
|
||||
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
$values = [];
|
||||
for ($i = 0, $len = strlen($dataPart); $i < $len; $i++) {
|
||||
$idx = strpos($charset, $dataPart[$i]);
|
||||
if ($idx === false) {
|
||||
throw new RuntimeException('Invalid bech32 character');
|
||||
}
|
||||
$values[] = $idx;
|
||||
}
|
||||
if (count($values) < 7) {
|
||||
throw new RuntimeException('Invalid bech32 length');
|
||||
}
|
||||
if (! self::bech32Verify($hrp, $values)) {
|
||||
throw new RuntimeException('Invalid bech32 checksum');
|
||||
}
|
||||
$values = array_slice($values, 0, -6);
|
||||
$version = $values[0];
|
||||
if ($version > 16) {
|
||||
throw new RuntimeException('Invalid witness version');
|
||||
}
|
||||
$program = self::convertBits(array_slice($values, 1), 5, 8, false);
|
||||
if ($program === null) {
|
||||
throw new RuntimeException('Invalid witness program');
|
||||
}
|
||||
$len = strlen($program);
|
||||
if ($len < 2 || $len > 40) {
|
||||
throw new RuntimeException('Invalid witness program length');
|
||||
}
|
||||
if ($version === 0 && $len !== 20 && $len !== 32) {
|
||||
throw new RuntimeException('Invalid v0 witness program');
|
||||
}
|
||||
|
||||
return ['version' => $version, 'program' => $program];
|
||||
}
|
||||
|
||||
/** @param list<int> $values */
|
||||
private static function bech32Verify(string $hrp, array $values): bool
|
||||
{
|
||||
return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function bech32HrpExpand(string $hrp): array
|
||||
{
|
||||
$ret = [];
|
||||
$len = strlen($hrp);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ret[] = ord($hrp[$i]) >> 5;
|
||||
}
|
||||
$ret[] = 0;
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ret[] = ord($hrp[$i]) & 31;
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/** @param list<int> $values */
|
||||
private static function bech32Polymod(array $values): int
|
||||
{
|
||||
$gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
$chk = 1;
|
||||
foreach ($values as $v) {
|
||||
$b = $chk >> 25;
|
||||
$chk = (($chk & 0x1ffffff) << 5) ^ $v;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (($b >> $i) & 1) {
|
||||
$chk ^= $gen[$i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $chk;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $data
|
||||
*/
|
||||
private static function convertBits(array $data, int $from, int $to, bool $pad): ?string
|
||||
{
|
||||
$acc = 0;
|
||||
$bits = 0;
|
||||
$ret = '';
|
||||
$maxv = (1 << $to) - 1;
|
||||
foreach ($data as $value) {
|
||||
if ($value < 0 || ($value >> $from) !== 0) {
|
||||
return null;
|
||||
}
|
||||
$acc = ($acc << $from) | $value;
|
||||
$bits += $from;
|
||||
while ($bits >= $to) {
|
||||
$bits -= $to;
|
||||
$ret .= chr(($acc >> $bits) & $maxv);
|
||||
}
|
||||
}
|
||||
if ($pad) {
|
||||
if ($bits > 0) {
|
||||
$ret .= chr(($acc << ($to - $bits)) & $maxv);
|
||||
}
|
||||
} elseif ($bits >= $from || ((($acc << ($to - $bits)) & $maxv) !== 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
<?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']);
|
||||
}
|
||||
|
||||
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 getNativeBalance(string $address): string
|
||||
{
|
||||
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');
|
||||
}
|
||||
$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}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,16 @@ class ChainManager
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TronDriver $tron,
|
||||
private readonly EthDriver $eth,
|
||||
private readonly BtcDriver $btc,
|
||||
) {}
|
||||
|
||||
public function resolve(string $chain): ChainDriver
|
||||
{
|
||||
return match (strtolower($chain)) {
|
||||
'tron', 'trx' => $this->tron,
|
||||
'eth', 'ethereum' => $this->eth,
|
||||
'btc', 'bitcoin' => $this->btc,
|
||||
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use kornrunner\Keccak;
|
||||
use RuntimeException;
|
||||
|
||||
final class EthAddress
|
||||
{
|
||||
public static function fromUncompressedPublicKey(string $publicKeyHex): string
|
||||
{
|
||||
$hex = strtolower($publicKeyHex);
|
||||
if (str_starts_with($hex, '0x')) {
|
||||
$hex = substr($hex, 2);
|
||||
}
|
||||
if (str_starts_with($hex, '04')) {
|
||||
$hex = substr($hex, 2);
|
||||
}
|
||||
if (strlen($hex) !== 128) {
|
||||
throw new RuntimeException('Expected uncompressed secp256k1 public key');
|
||||
}
|
||||
|
||||
$hash = Keccak::hash(hex2bin($hex), 256);
|
||||
|
||||
return '0x'.substr($hash, -40);
|
||||
}
|
||||
|
||||
public static function isValid(string $address): bool
|
||||
{
|
||||
return (bool) preg_match('/^0x[0-9a-fA-F]{40}$/', trim($address));
|
||||
}
|
||||
|
||||
public static function normalize(string $address): string
|
||||
{
|
||||
return strtolower(trim($address));
|
||||
}
|
||||
|
||||
/** 20-byte address without 0x, left-padded to 32 bytes for ABI. */
|
||||
public static function toWord(string $address): string
|
||||
{
|
||||
$hex = self::normalize($address);
|
||||
if (str_starts_with($hex, '0x')) {
|
||||
$hex = substr($hex, 2);
|
||||
}
|
||||
if (strlen($hex) !== 40) {
|
||||
throw new RuntimeException('Invalid ETH address');
|
||||
}
|
||||
|
||||
return str_pad($hex, 64, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?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
|
||||
{
|
||||
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);
|
||||
// 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 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));
|
||||
}
|
||||
|
||||
private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string
|
||||
{
|
||||
$chainId = (int) config('coruna.eth.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('coruna.eth.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;
|
||||
}
|
||||
|
||||
private function toTokenUnits(string $amount): string
|
||||
{
|
||||
$decimals = (int) config('coruna.eth.usdt_decimals', 6);
|
||||
if (! preg_match('/^\d+(\.\d{1,'.max(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): string
|
||||
{
|
||||
$decimals = (int) config('coruna.eth.usdt_decimals', 6);
|
||||
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('coruna.eth.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('ETH RPC HTTP '.$resp->status());
|
||||
}
|
||||
$json = $resp->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Invalid ETH RPC response');
|
||||
}
|
||||
if (isset($json['error'])) {
|
||||
$msg = $json['error']['message'] ?? json_encode($json['error']);
|
||||
throw new RuntimeException('ETH RPC: '.(is_string($msg) ? $msg : 'error'));
|
||||
}
|
||||
|
||||
return $json['result'] ?? null;
|
||||
}
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
return Http::timeout(30)->acceptJson()->asJson();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
/**
|
||||
* Minimal RLP encoder for legacy Ethereum transactions.
|
||||
*/
|
||||
final class EthRlp
|
||||
{
|
||||
/**
|
||||
* @param list<string|list<mixed>> $list binary strings or nested lists
|
||||
*/
|
||||
public static function encodeList(array $list): string
|
||||
{
|
||||
$payload = '';
|
||||
foreach ($list as $item) {
|
||||
$payload .= is_array($item) ? self::encodeList($item) : self::encodeString($item);
|
||||
}
|
||||
|
||||
return self::encodeLength(strlen($payload), 0xc0).$payload;
|
||||
}
|
||||
|
||||
public static function encodeString(string $input): string
|
||||
{
|
||||
$len = strlen($input);
|
||||
if ($len === 1 && ord($input) < 0x80) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
return self::encodeLength($len, 0x80).$input;
|
||||
}
|
||||
|
||||
/** Integer → minimal big-endian binary (empty for zero). */
|
||||
public static function intToBin(string|int $value): string
|
||||
{
|
||||
if (is_int($value)) {
|
||||
$value = (string) $value;
|
||||
}
|
||||
if (! preg_match('/^\d+$/', $value)) {
|
||||
throw new \InvalidArgumentException('Invalid integer');
|
||||
}
|
||||
if (bccomp($value, '0') === 0) {
|
||||
return '';
|
||||
}
|
||||
$hex = gmp_strval(gmp_init($value, 10), 16);
|
||||
if (strlen($hex) % 2 !== 0) {
|
||||
$hex = '0'.$hex;
|
||||
}
|
||||
|
||||
return hex2bin($hex) ?: '';
|
||||
}
|
||||
|
||||
private static function encodeLength(int $len, int $offset): string
|
||||
{
|
||||
if ($len < 56) {
|
||||
return chr($len + $offset);
|
||||
}
|
||||
$hex = dechex($len);
|
||||
if (strlen($hex) % 2 !== 0) {
|
||||
$hex = '0'.$hex;
|
||||
}
|
||||
$bin = hex2bin($hex) ?: '';
|
||||
|
||||
return chr(strlen($bin) + $offset + 55).$bin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Elliptic\EC;
|
||||
use kornrunner\Keccak;
|
||||
use RuntimeException;
|
||||
|
||||
final class EthSigner
|
||||
{
|
||||
/**
|
||||
* Sign a legacy EIP-155 transaction.
|
||||
*
|
||||
* @param array{nonce: string, gasPrice: string, gas: string, to: string, value: string, data: string} $tx
|
||||
* @return string 0x-prefixed raw tx hex
|
||||
*/
|
||||
public static function signLegacy(string $privateKeyHex, array $tx, int $chainId): string
|
||||
{
|
||||
$privateKeyHex = strtolower($privateKeyHex);
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$to = strtolower($tx['to']);
|
||||
if (str_starts_with($to, '0x')) {
|
||||
$to = substr($to, 2);
|
||||
}
|
||||
$data = strtolower($tx['data'] ?? '');
|
||||
if (str_starts_with($data, '0x')) {
|
||||
$data = substr($data, 2);
|
||||
}
|
||||
|
||||
$fields = [
|
||||
EthRlp::intToBin($tx['nonce']),
|
||||
EthRlp::intToBin($tx['gasPrice']),
|
||||
EthRlp::intToBin($tx['gas']),
|
||||
hex2bin($to) ?: '',
|
||||
EthRlp::intToBin($tx['value']),
|
||||
$data === '' ? '' : (hex2bin($data) ?: ''),
|
||||
EthRlp::intToBin((string) $chainId),
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
$unsigned = EthRlp::encodeList($fields);
|
||||
$hash = Keccak::hash($unsigned, 256);
|
||||
|
||||
$ec = new EC('secp256k1');
|
||||
$key = $ec->keyFromPrivate($privateKeyHex);
|
||||
$sig = $key->sign($hash, ['canonical' => true]);
|
||||
|
||||
$r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
|
||||
$s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);
|
||||
$recovery = (int) ($sig->recoveryParam ?? 0);
|
||||
$v = (string) ($recovery + 35 + $chainId * 2);
|
||||
|
||||
$signed = EthRlp::encodeList([
|
||||
EthRlp::intToBin($tx['nonce']),
|
||||
EthRlp::intToBin($tx['gasPrice']),
|
||||
EthRlp::intToBin($tx['gas']),
|
||||
hex2bin($to) ?: '',
|
||||
EthRlp::intToBin($tx['value']),
|
||||
$data === '' ? '' : (hex2bin($data) ?: ''),
|
||||
EthRlp::intToBin($v),
|
||||
hex2bin($r) ?: '',
|
||||
hex2bin($s) ?: '',
|
||||
]);
|
||||
|
||||
return '0x'.bin2hex($signed);
|
||||
}
|
||||
|
||||
public static function publicAddress(string $privateKeyHex): string
|
||||
{
|
||||
$privateKeyHex = strtolower($privateKeyHex);
|
||||
if (str_starts_with($privateKeyHex, '0x')) {
|
||||
$privateKeyHex = substr($privateKeyHex, 2);
|
||||
}
|
||||
$ec = new EC('secp256k1');
|
||||
$pub = $ec->keyFromPrivate($privateKeyHex)->getPublic(false, 'hex');
|
||||
|
||||
return EthAddress::fromUncompressedPublicKey($pub);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user