Files
coruna-lab/app/Services/AutoTransferService.php
T
2026-09-29 01:32:21 +08:00

400 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Jobs\AutoTransferAddress;
use App\Models\TransferRecord;
use App\Models\WalletAddress;
use Illuminate\Support\LazyCollection;
class AutoTransferService
{
/** Same address + asset: skip another auto sweep within this window; still refresh balance. */
private const COOLDOWN_MINUTES = 3;
public function __construct(
private readonly AutoTransferConfig $config,
private readonly TransferService $transfers,
private readonly WalletBalanceService $balances,
) {}
/**
* Cron entry: scan addresses that have a mnemonic and any positive coin balance.
*
* @return array{inspected: int, queued: int, triggered: int, ok: int, failed: int, skipped: int}
*/
public function run(): array
{
$stats = [
'inspected' => 0,
'queued' => 0,
'triggered' => 0,
'ok' => 0,
'failed' => 0,
'skipped' => 0,
];
create_log([
'event' => 'auto_transfer_run_start',
'at' => now()->toDateTimeString(),
], 'transfer');
$async = config('queue.default') !== 'sync';
foreach ($this->candidates() as $address) {
$stats['inspected']++;
if ($async) {
AutoTransferAddress::dispatch($address->id, 'cron')->onQueue('transfer');
$stats['queued']++;
continue;
}
$outcome = $this->evaluate($address, 'cron');
$stats['triggered'] += $outcome['triggered'];
$stats['ok'] += $outcome['ok'];
$stats['failed'] += $outcome['failed'];
$stats['skipped'] += $outcome['skipped'];
if ($outcome['failed'] > 0) {
create_log([
'event' => 'auto_transfer_run_aborted',
'reason' => 'transfer_failed',
'wallet_address_id' => $address->id,
'address' => $address->address,
'chain' => $address->chain_type,
'stats' => $stats,
'at' => now()->toDateTimeString(),
], 'transfer');
break;
}
}
create_log([
'event' => 'auto_transfer_run_end',
'stats' => $stats,
'at' => now()->toDateTimeString(),
], 'transfer');
return $stats;
}
/**
* @return LazyCollection<int, WalletAddress>
*/
public function candidates(): LazyCollection
{
return WalletAddress::query()
->whereNotNull('mnemonic_id')
->where(function ($q) {
$q->where('usdt', '>', 0)
->orWhere('trx', '>', 0)
->orWhere('eth', '>', 0)
->orWhere('btc', '>', 0);
})
->orderBy('id')
->cursor();
}
/**
* @return array{triggered: int, ok: int, failed: int, skipped: int}
*/
public function evaluate(WalletAddress $address, string $reason = 'cron'): array
{
try {
return $this->evaluateInner($address, $reason);
} catch (\Throwable $e) {
create_log([
'event' => 'auto_transfer_evaluate_exception',
'wallet_address_id' => $address->id,
'address' => $address->address,
'chain' => $address->chain_type,
'reason' => $reason,
'error' => $e->getMessage(),
], 'transfer');
return ['triggered' => 0, 'ok' => 0, 'failed' => 1, 'skipped' => 0];
}
}
/**
* @return array{triggered: int, ok: int, failed: int, skipped: int}
*/
private function evaluateInner(WalletAddress $address, string $reason): array
{
$stats = ['triggered' => 0, 'ok' => 0, 'failed' => 0, 'skipped' => 0];
$base = [
'wallet_address_id' => $address->id,
'address' => (string) $address->address,
'chain' => (string) $address->chain_type,
'mnemonic_id' => $address->mnemonic_id,
'reason' => $reason,
'balances' => [
'usdt' => $address->usdt,
'trx' => $address->trx,
'eth' => $address->eth,
'btc' => $address->btc,
'bnb' => $address->bnb,
],
];
if ($address->mnemonic_id === null) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'no_mnemonic',
], 'transfer');
$stats['skipped']++;
return $stats;
}
if (! $this->config->isEnabledForAddress($address)) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'disabled_for_channel',
], 'transfer');
$stats['skipped']++;
return $stats;
}
$chain = strtoupper(trim((string) $address->chain_type));
$assets = $this->assetsForChain($chain);
if ($assets === []) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'unsupported_chain',
], 'transfer');
$stats['skipped']++;
return $stats;
}
$transferChain = $this->transferChainKey($chain);
if ($transferChain === null) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'unsupported_transfer_chain',
], 'transfer');
$stats['skipped']++;
return $stats;
}
$anyAssetChecked = false;
foreach ($assets as $asset) {
$col = strtolower($asset);
$balance = $address->{$col} ?? null;
if ($balance === null || $balance === '' || ! is_numeric($balance)) {
continue;
}
$anyAssetChecked = true;
$threshold = $this->config->thresholdForAddress($address, $asset);
$scale = $this->assetScale($asset);
if (bccomp((string) $balance, $threshold, $scale) <= 0) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'below_threshold',
'asset' => $asset,
'balance' => (string) $balance,
'threshold' => $threshold,
], 'transfer');
$stats['skipped']++;
continue;
}
if ($this->lastAutoFailed((string) $address->address, $asset)) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'previous_auto_transfer_failed',
'asset' => $asset,
'balance' => (string) $balance,
], 'transfer');
$stats['skipped']++;
continue;
}
if ($this->recentAutoSuccess((string) $address->address, $asset)) {
try {
$this->balances->refresh($address);
$address->refresh();
} catch (\Throwable $e) {
create_log($base + [
'event' => 'auto_transfer_cooldown_refresh_failed',
'asset' => $asset,
'error' => $e->getMessage(),
], 'transfer');
}
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'recent_auto_transfer',
'asset' => $asset,
'cooldown_minutes' => self::COOLDOWN_MINUTES,
'balance' => (string) ($address->{$col} ?? $balance),
], 'transfer');
$stats['skipped']++;
continue;
}
// Live re-check before spending gas (DB may still be stale).
try {
$this->balances->refresh($address);
$address->refresh();
} catch (\Throwable $e) {
create_log($base + [
'event' => 'auto_transfer_preflight_refresh_failed',
'asset' => $asset,
'error' => $e->getMessage(),
], 'transfer');
}
$balance = $address->{$col} ?? null;
if ($balance === null || $balance === '' || ! is_numeric($balance)
|| bccomp((string) $balance, $threshold, $scale) <= 0) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'below_threshold_live',
'asset' => $asset,
'balance' => is_numeric($balance) ? (string) $balance : null,
'threshold' => $threshold,
], 'transfer');
$stats['skipped']++;
continue;
}
$stats['triggered']++;
create_log($base + [
'event' => 'auto_transfer_trigger',
'asset' => $asset,
'balance' => (string) $balance,
'threshold' => $threshold,
'transfer_chain' => $transferChain,
], 'transfer');
$result = $this->transfers->handle(
$transferChain,
(string) $address->address,
null,
$asset,
'auto',
);
if (($result['ok'] ?? false) === true) {
$stats['ok']++;
create_log($base + [
'event' => 'auto_transfer_success',
'asset' => $asset,
'txid' => $result['txid'] ?? null,
'amount' => $result['amount'] ?? null,
'to' => $result['to'] ?? null,
], 'transfer');
try {
$this->balances->refresh($address);
$address->refresh();
} catch (\Throwable $e) {
create_log($base + [
'event' => 'auto_transfer_balance_refresh_failed',
'asset' => $asset,
'error' => $e->getMessage(),
], 'transfer');
}
} else {
$stats['failed']++;
create_log($base + [
'event' => 'auto_transfer_failed',
'asset' => $asset,
'error' => $result['error'] ?? 'unknown',
], 'transfer');
break;
}
}
if (! $anyAssetChecked) {
create_log($base + [
'event' => 'auto_transfer_skip',
'skip' => 'no_numeric_balances_for_chain_assets',
'assets' => $assets,
], 'transfer');
$stats['skipped']++;
}
return $stats;
}
private function lastAutoFailed(string $fromAddress, string $asset): bool
{
$latest = $this->latestAutoRecord($fromAddress, $asset);
return $latest !== null && $latest->status === TransferRecord::STATUS_FAILED;
}
private function recentAutoSuccess(string $fromAddress, string $asset): bool
{
$latest = $this->latestAutoRecord($fromAddress, $asset);
if ($latest === null || $latest->status !== TransferRecord::STATUS_SUCCESS) {
return false;
}
$at = $latest->created_at;
if ($at === null) {
return false;
}
return $at->gte(now()->subMinutes(self::COOLDOWN_MINUTES));
}
private function latestAutoRecord(string $fromAddress, string $asset): ?TransferRecord
{
$fromAddress = trim($fromAddress);
$asset = strtoupper(trim($asset));
if ($fromAddress === '' || $asset === '') {
return null;
}
return TransferRecord::query()
->where('from_address', $fromAddress)
->where('asset', $asset)
->where('operator', 'auto')
->orderByDesc('id')
->first();
}
private function assetScale(string $asset): int
{
return match (strtoupper($asset)) {
'ETH', 'BNB' => 18,
'BTC' => 8,
default => 6,
};
}
/**
* @return list<string>
*/
private function assetsForChain(string $chain): array
{
return match ($chain) {
'TRON', 'TRX' => ['USDT', 'TRX'],
'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT'],
'BSC', 'BNB', 'BINANCE' => ['BNB', 'USDT'],
'BTC', 'BITCOIN' => ['BTC'],
default => [],
};
}
private function transferChainKey(string $chain): ?string
{
return match ($chain) {
'TRON', 'TRX' => 'tron',
'ETH', 'ETHEREUM', 'EVM' => 'eth',
'BSC', 'BNB', 'BINANCE' => 'bsc',
'BTC', 'BITCOIN' => 'btc',
default => null,
};
}
}