'', 'coruna.telegram.owner_chat_id' => '', ]); $tokenview = Mockery::mock(TokenviewMonitorService::class); $tokenview->shouldReceive('syncMonitor')->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_beyond_four_is_not_linked(): void { $device = $this->makeDevice('dev-link-4'); $tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 5); $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, ]); 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 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_derives_index_zero_when_mnemonic_has_no_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=3') ->assertSuccessful(); $rows = WalletAddress::query() ->where('mnemonic_id', $mnemonic->id) ->orderBy('id') ->get(); $this->assertCount(3, $rows); $this->assertSame(['TRON', 'ETH', 'BTC'], $rows->pluck('chain_type')->all()); $this->assertSame([ self::TRON_0, self::ETH_0, '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', ], $rows->pluck('address')->all()); $this->assertSame([0, 0, 0], $rows->pluck('derive_index')->all()); } }