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 */ 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 */ 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, }; } }