400 lines
13 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|