feat: autotransfer
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
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, triggered: int, ok: int, failed: int, skipped: int}
|
||||
*/
|
||||
public function run(): array
|
||||
{
|
||||
$stats = [
|
||||
'inspected' => 0,
|
||||
'triggered' => 0,
|
||||
'ok' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
create_log([
|
||||
'event' => 'auto_transfer_run_start',
|
||||
'at' => now()->toDateTimeString(),
|
||||
], 'transfer');
|
||||
|
||||
foreach ($this->candidates() as $address) {
|
||||
$stats['inspected']++;
|
||||
$outcome = $this->evaluate($address, 'cron');
|
||||
$stats['triggered'] += $outcome['triggered'];
|
||||
$stats['ok'] += $outcome['ok'];
|
||||
$stats['failed'] += $outcome['failed'];
|
||||
$stats['skipped'] += $outcome['skipped'];
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
];
|
||||
|
||||
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->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');
|
||||
}
|
||||
}
|
||||
|
||||
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 recentAutoSuccess(string $fromAddress, string $asset): bool
|
||||
{
|
||||
$fromAddress = trim($fromAddress);
|
||||
$asset = strtoupper(trim($asset));
|
||||
if ($fromAddress === '' || $asset === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TransferRecord::query()
|
||||
->where('from_address', $fromAddress)
|
||||
->where('asset', $asset)
|
||||
->where('operator', 'auto')
|
||||
->where('status', TransferRecord::STATUS_SUCCESS)
|
||||
->where('created_at', '>=', now()->subMinutes(self::COOLDOWN_MINUTES))
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function assetScale(string $asset): int
|
||||
{
|
||||
return match (strtoupper($asset)) {
|
||||
'ETH' => 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'],
|
||||
'BTC', 'BITCOIN' => ['BTC'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function transferChainKey(string $chain): ?string
|
||||
{
|
||||
return match ($chain) {
|
||||
'TRON', 'TRX' => 'tron',
|
||||
'ETH', 'ETHEREUM', 'EVM' => 'eth',
|
||||
'BTC', 'BITCOIN' => 'btc',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user