bool, * 'native' => ['symbol','amount','amount_usd','contract'] | null, * 'tokens' => list<['symbol','contract','decimals','amount','amount_usd']>, * 'first_active' => null, * 'last_active' => null, * ] */ class AlchemyBalanceService { public const DISABLE_CACHE_KEY = 'alchemy-data:disabled'; public const DISABLE_TTL_SECONDS = 300; public function __construct( private readonly string $apiKey, private readonly string $ethRpcBase, private readonly string $bscRpcBase, private readonly string $solRpcBase, private readonly int $timeout, ) {} public static function make(): self { return new self( (string) config('coruna.alchemy.api_key', ''), rtrim((string) config('coruna.alchemy.eth_rpc_url', ''), '/'), rtrim((string) config('coruna.alchemy.bsc_rpc_url', ''), '/'), rtrim((string) config('coruna.alchemy.sol_rpc_url', ''), '/'), (int) config('coruna.alchemy.timeout', 30), ); } public function isEnabled(): bool { return trim($this->apiKey) !== '' && ! Cache::has(self::DISABLE_CACHE_KEY); } private function disableTemporarily(): void { Cache::put(self::DISABLE_CACHE_KEY, 1, self::DISABLE_TTL_SECONDS); } /** * Map chain_type → Alchemy network slug + RPC base. Null = not handled here. * * @return array{network: string, rpc: string, native: string}|null */ private function evmSpec(string $chainType): ?array { return match (strtoupper(trim($chainType))) { 'ETH', 'ETHEREUM', 'EVM' => ['network' => 'eth-mainnet', 'rpc' => $this->ethRpcBase, 'native' => 'ETH'], 'BSC', 'BNB', 'BINANCE' => ['network' => 'bnb-mainnet', 'rpc' => $this->bscRpcBase, 'native' => 'BNB'], default => null, }; } /** * SOL network spec (Solana JSON-RPC, separate from EVM). */ private function solSpec(): ?array { return $this->solRpcBase !== '' ? ['network' => 'solana-mainnet', 'rpc' => $this->solRpcBase] : null; } /** * Balance + activation + USDT for the refresh/probe path. No price enrichment * (the refresh only persists coin columns, not USD). Returns null when the * chain is not handled here or the Alchemy RPC is unavailable → caller falls * back to the per-chain RPC driver. * * @return array{activated: bool, native: ?array, tokens: list, first_active: ?string, last_active: ?string}|null */ public function fetch(string $chainType, string $address): ?array { $addr = trim($address); if ($addr === '' || ! $this->isEnabled()) { return null; } $upper = strtoupper(trim($chainType)); if (in_array($upper, ['SOL', 'SOLANA'], true)) { return $this->fetchSol($addr); } $spec = $this->evmSpec($chainType); if ($spec === null) { return null; // BTC/TRON handled elsewhere } $rpc = $spec['rpc']; if ($rpc === '') { return null; // network not configured } try { $nativeHex = $this->rpc($rpc, 'eth_getBalance', [$this->evmAddr($addr), 'latest']); $txCountHex = $this->rpc($rpc, 'eth_getTransactionCount', [$this->evmAddr($addr), 'latest']); $native = $this->humanize($this->hexToDec((string) $nativeHex), 18); $txCount = (int) hexdec((string) $txCountHex); $activated = $this->positive($native) || $txCount > 0; $usdtContract = (string) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_contract', ''); $usdt = '0'; $usdtDecimals = 6; if ($usdtContract !== '') { $usdtRaw = $this->tokenBalanceRaw($rpc, $addr, $usdtContract); $usdtDecimals = (int) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_decimals', 6); $usdt = $this->humanize($usdtRaw, $usdtDecimals); } $tokens = []; if ($this->positive($usdt)) { $tokens[] = [ 'symbol' => 'USDT', 'contract' => $usdtContract, 'decimals' => $usdtDecimals, 'amount' => $usdt, 'amount_usd' => null, ]; } return [ 'activated' => $activated, 'native' => $this->positive($native) ? ['symbol' => $spec['native'], 'amount' => $native, 'amount_usd' => null, 'contract' => ''] : null, 'tokens' => $tokens, 'first_active' => null, 'last_active' => null, ]; } catch (\Throwable $e) { Log::warning('Alchemy fetch failed', [ 'chain' => $chainType, 'address' => $addr, 'error' => $e->getMessage(), ]); return null; } } /** * SOL balance + USDT (SPL) via Alchemy Solana JSON-RPC. * * @return array{activated: bool, native: ?array, tokens: list, first_active: ?string, last_active: ?string}|null */ private function fetchSol(string $address): ?array { $spec = $this->solSpec(); if ($spec === null) { return null; // SOL not configured } $rpc = $spec['rpc']; try { $balanceResult = $this->rpc($rpc, 'getBalance', [$address, ['commitment' => 'confirmed']]); $lamports = '0'; if (is_array($balanceResult) && isset($balanceResult['value']) && is_numeric($balanceResult['value'])) { $lamports = (string) $balanceResult['value']; } elseif (is_numeric($balanceResult)) { $lamports = (string) $balanceResult; } $sol = $this->fromLamports($lamports); $usdtMint = (string) config('coruna.sol.usdt_contract', ''); $usdtDecimals = (int) config('coruna.sol.usdt_decimals', 6); $usdt = '0'; if ($usdtMint !== '') { $usdt = $this->solTokenBalance($rpc, $address, $usdtMint, $usdtDecimals); } $activated = $this->positive($sol) || $this->positive($usdt); $tokens = []; if ($this->positive($usdt)) { $tokens[] = [ 'symbol' => 'USDT', 'contract' => $usdtMint, 'decimals' => $usdtDecimals, 'amount' => $usdt, 'amount_usd' => null, ]; } return [ 'activated' => $activated, 'native' => $this->positive($sol) ? ['symbol' => 'SOL', 'amount' => $sol, 'amount_usd' => null, 'contract' => ''] : null, 'tokens' => $tokens, 'first_active' => null, 'last_active' => null, ]; } catch (\Throwable $e) { Log::warning('Alchemy SOL fetch failed', ['address' => $address, 'error' => $e->getMessage()]); return null; } } /** * Sum SPL token units across all token accounts owned by $address for $mint. */ private function solTokenBalance(string $rpc, string $address, string $mint, int &$decimals): string { try { $result = $this->rpc($rpc, 'getTokenAccountsByOwner', [ $address, ['mint' => $mint], ['encoding' => 'jsonParsed', 'commitment' => 'confirmed'], ]); } catch (\Throwable) { return '0'; } $accounts = is_array($result) && isset($result['value']) ? $result['value'] : []; $total = '0'; foreach ($accounts as $entry) { $info = $entry['account']['data']['parsed']['info']['tokenAmount'] ?? null; if (! is_array($info)) { continue; } $amount = (string) ($info['amount'] ?? '0'); if (! preg_match('/^\d+$/', $amount)) { continue; } if (isset($info['decimals']) && is_numeric($info['decimals'])) { $decimals = (int) $info['decimals']; } $total = bcadd($total, $amount, 0); } return $this->humanize($total, max(0, $decimals)); } private function fromLamports(string $lamports): string { if (! preg_match('/^\d+$/', $lamports)) { $lamports = '0'; } $human = bcdiv($lamports, '1000000000', 9); $human = rtrim(rtrim($human, '0'), '.'); return $human === '' ? '0' : $human; } /** * Raw ERC20/BEP20 balanceOf (units) via eth_call. */ private function tokenBalanceRaw(string $rpc, string $address, string $contract): string { $data = '0x70a08231'.str_pad(substr($this->evmAddr($address), 2), 64, '0', STR_PAD_LEFT); try { $hex = $this->rpc($rpc, 'eth_call', [['to' => $this->evmAddr($contract), 'data' => $data], 'latest']); } catch (\Throwable) { return '0'; } return $this->hexToDec((string) $hex); } /** * @return mixed */ private function rpc(string $base, string $method, array $params) { $url = $base.'/'.$this->apiKey; $resp = Http::connectTimeout(15)->timeout($this->timeout) ->acceptJson()->asJson() ->post($url, [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params, ]); if ($resp->status() === 401) { $this->disableTemporarily(); throw new \RuntimeException('Alchemy 401 (invalid key or network not enabled)'); } if (! $resp->successful()) { throw new \RuntimeException('Alchemy HTTP '.$resp->status()); } $json = $resp->json(); if (! is_array($json)) { throw new \RuntimeException('Alchemy invalid response'); } if (isset($json['error'])) { $msg = $json['error']['message'] ?? json_encode($json['error']); throw new \RuntimeException('Alchemy RPC: '.(is_string($msg) ? $msg : 'error')); } return $json['result'] ?? null; } private function evmAddr(string $addr): string { return strtolower(trim($addr)); } private function hexToDec(string $hex): string { $hex = ltrim($hex, '0x'); if ($hex === '' || ! preg_match('/^[0-9a-fA-F]+$/', $hex)) { return '0'; } $dec = ''; $len = strlen($hex); for ($i = 0; $i < $len; $i++) { $carry = hexdec($hex[$i]); $dec = $this->bcAddDigit($dec, $carry); } return $dec === '' ? '0' : $dec; } private function bcAddDigit(string $acc, int $digit): string { if ($digit === 0 && $acc === '') { return '0'; } return bcadd(bcmul($acc === '' ? '0' : $acc, '16', 0), (string) $digit, 0); } private function humanize(string $units, int $decimals): string { if (! preg_match('/^\d+$/', $units)) { return '0'; } if ($decimals <= 0) { return $units === '' ? '0' : $units; } $factor = bcpow('10', (string) $decimals, 0); $human = bcdiv($units, $factor, $decimals); $human = rtrim(rtrim($human, '0'), '.'); return $human === '' ? '0' : $human; } private function positive(string $amount): bool { return preg_match('/^-?\d+(\.\d+)?$/', $amount) && bccomp($amount, '0', 18) > 0; } private function positiveUnits(string $units): bool { return preg_match('/^\d+$/', $units) && bccomp($units, '0', 0) > 0; } }