getNativeBalance($address), '0', 9) > 0; } catch (\Throwable) { return false; } } public function getNativeBalance(string $address): string { if (! $this->isValidAddress($address)) { throw new RuntimeException('Invalid SOL address'); } $result = $this->rpc('getBalance', [$address, ['commitment' => 'confirmed']]); $lamports = '0'; if (is_array($result) && isset($result['value']) && is_numeric($result['value'])) { $lamports = (string) $result['value']; } elseif (is_numeric($result)) { $lamports = (string) $result; } return $this->fromLamports($lamports); } /** * SPL token balance: sums all token accounts owned by $address for $contract (mint). * Handles the associated-token-account, secondary accounts and multi-decimal edge cases. */ public function getTokenBalance(string $address, string $contract): string { if (! $this->isValidAddress($address) || ! $this->isValidAddress($contract)) { throw new RuntimeException('Invalid SOL address'); } $result = $this->rpc('getTokenAccountsByOwner', [ $address, ['mint' => $contract], ['encoding' => 'jsonParsed', 'commitment' => 'confirmed'], ]); $accounts = []; if (is_array($result) && isset($result['value']) && is_array($result['value'])) { $accounts = $result['value']; } $totalUnits = '0'; $decimals = (int) config('coruna.sol.usdt_decimals', 6); 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']; } $totalUnits = bcadd($totalUnits, $amount, 0); } return $this->fromTokenUnits($totalUnits, 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; } private function fromTokenUnits(string $units, int $decimals): string { if (! preg_match('/^\d+$/', $units)) { $units = '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 rpc(string $method, array $params): mixed { $url = rtrim((string) config('coruna.sol.rpc_url', 'https://api.mainnet-beta.solana.com'), '/'); $resp = $this->http()->post($url, [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params, ]); if (! $resp->successful()) { throw new RuntimeException('SOL RPC HTTP '.$resp->status()); } $json = $resp->json(); if (! is_array($json)) { throw new RuntimeException('Invalid SOL RPC response'); } if (isset($json['error'])) { $msg = $json['error']['message'] ?? json_encode($json['error']); throw new RuntimeException('SOL RPC: '.(is_string($msg) ? $msg : 'error')); } return $json['result'] ?? null; } private function http(): PendingRequest { $req = Http::timeout(30)->acceptJson()->asJson(); $apiKey = (string) config('coruna.sol.api_key', ''); if ($apiKey !== '') { $req = $req->withHeaders(['Authorization' => 'Bearer '.$apiKey]); } return $req; } }