$device->id, 'source' => $source, ]); $row->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $row->save(); return $row; } #[Test] public function wallets_endpoint_returns_linked_address_balances(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-wallets']); $mnemonic = $this->storeMnemonic($device); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'chain_type' => 'TRON', 'source' => 'imToken', 'mnemonic_id' => $mnemonic->id, 'derive_index' => 0, 'usdt' => 12.5, 'trx' => 3.1, ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => '0x9858effd232b4033e47d90003d41ec34ecaeda94', 'chain_type' => 'ETH', 'source' => 'imToken', 'mnemonic_id' => $mnemonic->id, 'derive_index' => 0, 'eth' => 1.25, ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'unlinked-addr', 'chain_type' => 'BTC', 'source' => 'imToken', 'btc' => 9, ]); $wallets = $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.mnemonic_id', $mnemonic->id) ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') ->assertJsonPath('data.addresses.0.usdt', '12.5') ->assertJsonPath('data.addresses.0.trx', '3.1') ->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94') ->assertJsonPath('data.addresses.1.eth', '1.25') ->assertJsonCount(2, 'data.addresses'); $this->assertStringNotContainsString( 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', $wallets->getContent() ); } #[Test] public function wallets_endpoint_respects_agent_scope(): void { Http::fake(); $agent = User::query()->create(['username' => 'agent-w', 'password' => 'secret12', 'status' => 1]); $chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; $chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; Channel::query()->create(['channel_id' => $chA, 'user_id' => $agent->id, 'status' => 1]); Channel::query()->create(['channel_id' => $chB, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]); $mine = Device::query()->create(['device_id' => 'dev-mine', 'channel_id' => $chA]); $other = Device::query()->create(['device_id' => 'dev-other', 'channel_id' => $chB]); $mnMine = $this->storeMnemonic($mine); $mnOther = $this->storeMnemonic($other, 'Trust'); $this->actingAs($agent, 'agent') ->getJson(route('user.mnemonics.wallets', $mnMine)) ->assertOk() ->assertJsonPath('code', 0); $this->actingAs($agent, 'agent') ->getJson(route('user.mnemonics.wallets', $mnOther)) ->assertForbidden(); } #[Test] public function mnemonic_list_includes_wallets_url(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-list']); $mnemonic = $this->storeMnemonic($device); $list = $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.id', $mnemonic->id) ->assertJsonPath('data.0.mnemonic', 'abandon *** about') ->assertJsonPath('data.0.wallets_url', route('admin.mnemonics.wallets', $mnemonic)) ->assertJsonPath('data.0.refresh_url', route('admin.mnemonics.wallets.refresh', $mnemonic)); $this->assertStringNotContainsString( 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', $list->getContent() ); } #[Test] public function agent_list_hides_origin_device_when_scan_hidden(): void { config(['coruna.scan.agent_visible' => false]); $agent = User::query()->create(['username' => 'agent-origin', 'password' => 'secret12', 'status' => 1]); $ch = 'cccccccccccccccccccccccccccccccc'; Channel::query()->create(['channel_id' => $ch, 'user_id' => $agent->id, 'status' => 1]); $origin = Device::query()->create(['device_id' => 'dev-origin-hidden', 'channel_id' => $ch]); $holder = Device::query()->create(['device_id' => 'dev-holder-shown', 'channel_id' => $ch]); $mnemonic = $this->storeMnemonic($holder); $mnemonic->origin_device_id = $origin->id; $mnemonic->save(); $this->actingAs($agent, 'agent') ->getJson(route('user.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.origin_device_key', ''); $this->actingAs($agent, 'agent') ->get(route('user.mnemonics.index')) ->assertOk() ->assertDontSee('原设备'); $admin = Admin::query()->create(['username' => 'admin-origin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.data')) ->assertOk() ->assertJsonPath('data.0.origin_device_key', 'dev-origin-hidden'); $this->actingAs($admin, 'admin') ->get(route('admin.mnemonics.index')) ->assertOk() ->assertSee('原设备'); } #[Test] public function refresh_updates_linked_balances_and_throttles(): void { Http::fake([ '*/v1/accounts/*' => Http::response([ 'data' => [[ 'balance' => 2_000_000, 'trc20' => [ ['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '5000000'], ], ]], 'success' => true, ], 200), '*' => Http::response([], 404), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-refresh']); $mnemonic = $this->storeMnemonic($device); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6', 'chain_type' => 'TRON', 'source' => 'imToken', 'mnemonic_id' => $mnemonic->id, 'usdt' => 0, 'trx' => 0, ]); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.refreshed', 1) ->assertJsonPath('data.addresses.0.trx', '2') ->assertJsonPath('data.addresses.0.usdt', '5'); $addr->refresh(); $this->assertEqualsWithDelta(2.0, (float) $addr->trx, 0.0000001); $this->assertEqualsWithDelta(5.0, (float) $addr->usdt, 0.0000001); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic)) ->assertStatus(429) ->assertJsonPath('code', 1) ->assertJsonStructure(['data' => ['retry_after']]); } #[Test] public function wallets_discovers_activated_indexes_when_no_linked_addresses(): void { $this->fakeDiscoveryRpcs([ 'tron' => ['TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'], ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-discover']); $mnemonic = $this->storeMnemonic($device); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.updating', true) ->assertJsonPath('data.addresses.0.persisted', false); $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.updating', false) ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') ->assertJsonPath('data.addresses.0.chain_type', 'TRON') ->assertJsonPath('data.addresses.0.trx', '5') ->assertJsonPath('data.addresses.0.active', true) ->assertJsonPath('data.addresses.0.persisted', true) ->assertJsonCount(1, 'data.addresses'); $this->assertDatabaseHas('wallet_addresses', [ 'device_id' => $device->id, 'address' => 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH', 'mnemonic_id' => $mnemonic->id, 'derive_index' => 0, 'chain_type' => 'TRON', 'monitor' => 1, ]); $this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); } #[Test] public function wallets_persists_activated_index_within_first_three(): void { $tron2 = (new TronDriver)->deriveAddress( 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 2, ); $this->fakeDiscoveryRpcs([ 'tron' => [$tron2], ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-index2']); $mnemonic = $this->storeMnemonic($device); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.addresses.0.address', $tron2) ->assertJsonPath('data.addresses.0.derive_index', 2) ->assertJsonCount(1, 'data.addresses'); $this->assertDatabaseHas('wallet_addresses', [ 'device_id' => $device->id, 'address' => $tron2, 'mnemonic_id' => $mnemonic->id, 'derive_index' => 2, ]); $this->assertSame(1, WalletAddress::query()->where('device_id', $device->id)->count()); } #[Test] public function wallets_skips_activated_index_beyond_first_three(): void { $tron3 = (new TronDriver)->deriveAddress( 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 3, ); $this->fakeDiscoveryRpcs([ 'tron' => [$tron3], ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-index3']); $mnemonic = $this->storeMnemonic($device); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->assertOk() ->assertJsonPath('data.updating', true) ->assertJsonPath('data.addresses.0.persisted', false); $this->actingAs($admin, 'admin') ->postJson(route('admin.mnemonics.wallets.refresh', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0); $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); $this->assertDatabaseMissing('wallet_addresses', [ 'address' => $tron3, ]); } #[Test] public function wallets_previews_inactive_index_zero_without_persisting(): void { $this->fakeDiscoveryRpcs([]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-empty']); $mnemonic = $this->storeMnemonic($device); $this->actingAs($admin, 'admin') ->getJson(route('admin.mnemonics.wallets', $mnemonic)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') ->assertJsonPath('data.addresses.0.chain_type', 'TRON') ->assertJsonPath('data.addresses.0.derive_index', 0) ->assertJsonPath('data.addresses.0.active', false) ->assertJsonPath('data.addresses.0.persisted', false) ->assertJsonPath('data.addresses.0.id', null) ->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94') ->assertJsonPath('data.addresses.1.chain_type', 'ETH') ->assertJsonPath('data.addresses.1.active', false) ->assertJsonPath('data.addresses.2.chain_type', 'BSC') ->assertJsonPath('data.addresses.3.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA') ->assertJsonPath('data.addresses.3.chain_type', 'BTC') ->assertJsonPath('data.addresses.3.active', false); $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); } /** * @param array{tron?: list, eth?: list, btc?: list} $activated */ private function fakeDiscoveryRpcs(array $activated): void { config([ 'coruna.tron.full_node' => 'https://api.trongrid.io', 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', 'coruna.eth.rpc_url' => 'https://ethereum.example', 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', 'coruna.btc.api_url' => 'https://mempool.example/api', ]); $tronOn = array_fill_keys($activated['tron'] ?? [], true); $ethOn = []; foreach ($activated['eth'] ?? [] as $addr) { $ethOn[strtolower($addr)] = true; } $btcOn = array_fill_keys($activated['btc'] ?? [], true); Http::fake(function ($request) use ($tronOn, $ethOn, $btcOn) { $url = $request->url(); if (str_contains($url, '/wallet/getaccount')) { $addr = (string) ($request->data()['address'] ?? ''); if (isset($tronOn[$addr])) { return Http::response([ 'address' => $addr, 'create_time' => 1, 'balance' => 5_000_000, ], 200); } return Http::response([], 200); } if (str_contains($url, '/wallet/triggerconstantcontract')) { return Http::response(['constant_result' => ['0']], 200); } if (str_contains($url, 'ethereum.example')) { $body = $request->data(); $method = $body['method'] ?? ''; $target = strtolower((string) (($body['params'][0] ?? ''))); if ($method === 'eth_call') { $target = ''; } $hit = $target !== '' && isset($ethOn[$target]); if (in_array($method, ['eth_getTransactionCount', 'eth_getBalance', 'eth_call'], true)) { return Http::response([ 'jsonrpc' => '2.0', 'id' => 1, 'result' => $hit ? '0x1' : '0x0', ], 200); } } if (str_contains($url, 'mempool.example')) { foreach (array_keys($btcOn) as $addr) { if (str_contains($url, rawurlencode($addr)) || str_contains($url, $addr)) { return Http::response([ 'chain_stats' => [ 'tx_count' => 1, 'funded_txo_count' => 1, 'funded_txo_sum' => 100_000, 'spent_txo_sum' => 0, ], ], 200); } } return Http::response([ 'chain_stats' => [ 'tx_count' => 0, 'funded_txo_count' => 0, 'funded_txo_sum' => 0, 'spent_txo_sum' => 0, ], ], 200); } return Http::response([], 404); }); } }