'', 'coruna.telegram.owner_chat_id' => '', ]); $tokenview = Mockery::mock(TokenviewMonitorService::class); $tokenview->shouldReceive('syncMonitor')->andReturn(false); $tokenview->shouldReceive('shouldMonitor')->andReturn(false); $this->app->instance(TokenviewMonitorService::class, $tokenview); Http::fake(); } private function makeDevice(string $id = 'dev-link-1'): Device { return Device::query()->create([ 'device_id' => $id, 'ios_version' => '18.0', 'device_model' => 'iPhone', ]); } 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 linking_address_after_mnemonic_sets_fk(): void { $device = $this->makeDevice(); $mnemonic = $this->storeMnemonic($device); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => self::TRON_0, 'chain_type' => 'TRON', 'source' => 'MetaMask', 'monitor' => 0, ]); $linked = app(MnemonicAddressLinker::class)->linkAddress($addr); $this->assertTrue($linked); $addr->refresh(); $this->assertSame($mnemonic->id, $addr->mnemonic_id); $this->assertSame(0, $addr->derive_index); } #[Test] public function linking_mnemonic_after_addresses_backfills(): void { $device = $this->makeDevice('dev-link-2'); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => self::TRON_0, 'chain_type' => 'TRON', 'source' => 'TronLink', 'monitor' => 0, ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => self::ETH_0, 'chain_type' => 'ETHEREUM', 'source' => 'MetaMask', 'monitor' => 0, ]); $mnemonic = $this->storeMnemonic($device, 'imToken'); $count = app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic); $this->assertSame(2, $count); $this->assertSame(2, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); $this->assertSame( [0, 0], WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->orderBy('id')->pluck('derive_index')->all() ); } #[Test] public function mnemonic_link_only_derives_chains_present_on_device(): void { $device = $this->makeDevice('dev-link-3'); $tronDriver = new TronDriver; $ethDriver = new EthDriver; // Only TRON + ETH on device — BTC must not be required for a match. WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => $tronDriver->deriveAddress(self::MNEMONIC, 1), 'chain_type' => 'TRON', 'source' => 'a', 'monitor' => 0, ]); WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => $ethDriver->deriveAddress(self::MNEMONIC, 2), 'chain_type' => 'ETH', 'source' => 'b', 'monitor' => 0, ]); $mnemonic = $this->storeMnemonic($device); $count = app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic); $this->assertSame(2, $count); $rows = WalletAddress::query()->where('device_id', $device->id)->orderBy('id')->get(); $this->assertSame(1, $rows[0]->derive_index); $this->assertSame(2, $rows[1]->derive_index); $this->assertSame($mnemonic->id, $rows[0]->mnemonic_id); $this->assertSame($mnemonic->id, $rows[1]->mnemonic_id); } #[Test] public function derive_index_nine_is_linked(): void { $device = $this->makeDevice('dev-link-4a'); $tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 9); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => $tron, 'chain_type' => 'TRON', 'source' => 'imToken', 'monitor' => 0, ]); $mnemonic = $this->storeMnemonic($device); $this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr)); $addr->refresh(); $this->assertSame($mnemonic->id, $addr->mnemonic_id); $this->assertSame(9, $addr->derive_index); } #[Test] public function derive_index_beyond_nine_is_not_linked(): void { $device = $this->makeDevice('dev-link-4'); $tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 10); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => $tron, 'chain_type' => 'TRON', 'source' => 'imToken', 'monitor' => 0, ]); $mnemonic = $this->storeMnemonic($device); $this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr)); $this->assertSame(0, app(MnemonicAddressLinker::class)->linkMnemonicToDeviceAddresses($mnemonic)); $addr->refresh(); $this->assertNull($addr->mnemonic_id); $this->assertNull($addr->derive_index); } #[Test] public function unmatched_address_stays_unlinked(): void { $device = $this->makeDevice('dev-link-5'); $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)); $addr->refresh(); $this->assertNull($addr->mnemonic_id); } #[Test] public function ingest_address_after_mnemonic_links_via_ingest(): void { $device = $this->makeDevice('dev-link-ingest-1'); app(IngestService::class)->ingestMnemonic($device, [ 'a' => 'b', 'result' => self::MNEMONIC, ]); Http::fake([ '*' => Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200), ]); app(IngestService::class)->ingestAddresses($device, [ 'a' => 'a', 'ba' => [ self::ETH_0 => [ ['balance' => '0', 'chainType' => 'ETHEREUM', 'symbol' => 'ETH', 'decimal' => 18], ], ], ]); $addr = WalletAddress::query()->where('address', self::ETH_0)->first(); $this->assertNotNull($addr); $this->assertNotNull($addr->mnemonic_id); $this->assertSame(0, $addr->derive_index); $this->assertSame( WalletMnemonic::query()->where('device_id', $device->id)->value('id'), $addr->mnemonic_id ); } #[Test] public function new_eth_address_with_bsc_funds_also_creates_bsc_row(): void { config([ 'coruna.bsc.rpc_url' => 'https://bsc.example', 'coruna.bsc.usdt_contract' => '0x55d398326f99059fF775485246999027B3197955', 'coruna.bsc.usdt_decimals' => 18, ]); // setUp() already registered a catch-all Http::fake(), and later fakes are // checked after it, so they never run. Replace the client first. Http::swap(new \Illuminate\Http\Client\Factory); Http::fake([ 'https://bsc.example' => Http::sequence() ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0xde0b6b3a7640000'], 200) ->push(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x1bc16d674ec80000'], 200), ]); $device = $this->makeDevice('dev-link-bsc'); app(IngestService::class)->ingestAddresses($device, [ 'a' => 'a', 'ba' => [ self::ETH_0 => [ ['balance' => '0.1', 'chainType' => 'ETHEREUM', 'symbol' => 'ETH', 'decimal' => 18], ], ], ]); $eth = WalletAddress::query()->where('chain_type', 'ETHEREUM')->where('address', self::ETH_0)->first(); $bsc = WalletAddress::query()->where('chain_type', 'BSC')->where('address', self::ETH_0)->first(); $this->assertNotNull($eth); $this->assertNotNull($bsc); $this->assertEqualsWithDelta(1.0, (float) $bsc->bnb, 0.0000001); $this->assertEqualsWithDelta(2.0, (float) $bsc->usdt, 0.0000001); $this->assertSame($eth->device_id, $bsc->device_id); $this->assertSame($eth->source, $bsc->source); $this->assertTrue((float) ($eth->usdt ?? 0) < 0.0000001); $this->assertSame(1, WalletAddress::query()->where('chain_type', 'ETHEREUM')->count()); $this->assertSame(1, WalletAddress::query()->where('chain_type', 'BSC')->count()); } #[Test] public function ingest_mnemonic_after_address_links_via_ingest(): void { $device = $this->makeDevice('dev-link-ingest-2'); app(IngestService::class)->ingestAddresses($device, [ 'a' => 'c', 'ba' => [ self::TRON_0 => [ ['balance' => '0', 'chainType' => 'TRON', 'symbol' => 'TRX', 'decimal' => 6], ], ], ]); $addr = WalletAddress::query()->where('address', self::TRON_0)->first(); $this->assertNotNull($addr); $this->assertNull($addr->mnemonic_id); app(IngestService::class)->ingestMnemonic($device, [ 'a' => 'b', 'result' => self::MNEMONIC, ]); $addr->refresh(); $this->assertNotNull($addr->mnemonic_id); $this->assertSame(0, $addr->derive_index); } #[Test] public function backfill_links_existing_unlinked_addresses(): void { $device = Device::query()->create(['device_id' => 'dev-backfill-1']); $mnemonic = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $mnemonic->mnemonic = self::MNEMONIC; $mnemonic->save(); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, 'address' => self::TRON_0, 'chain_type' => 'TRON', 'source' => 'imToken', 'monitor' => 0, ]); $this->assertNull($addr->mnemonic_id); $this->artisan('coruna:link-mnemonics') ->expectsOutputToContain('linked=1 discovered=0') ->assertSuccessful(); $addr->refresh(); $this->assertSame($mnemonic->id, $addr->mnemonic_id); $this->assertSame(0, $addr->derive_index); } #[Test] public function backfill_skips_inactive_derived_addresses(): void { $device = Device::query()->create(['device_id' => 'dev-backfill-derive']); $mnemonic = new WalletMnemonic([ 'device_id' => $device->id, 'source' => 'imToken', ]); $mnemonic->mnemonic = self::MNEMONIC; $mnemonic->save(); $this->artisan('coruna:link-mnemonics') ->expectsOutputToContain('linked=0 discovered=0') ->assertSuccessful(); $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); } }