366 lines
12 KiB
PHP
366 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Alchemy;
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Balance + activation via the Alchemy Blockchain Data API (JSON-RPC) for ETH,
|
|
* BSC, and SOL. BTC is not handled here (Alchemy has no address-balance API on
|
|
* the current plan) — BTC stays on the BtcDriver (mempool.space). TRON stays on
|
|
* TronGrid.
|
|
*
|
|
* On 401 (invalid key / network not enabled) the service is flagged unavailable
|
|
* for a short cool-down so callers skip straight to the per-chain RPC fallback.
|
|
*
|
|
* Normalized result shape (compatible with the former Tokenview service):
|
|
* [
|
|
* 'activated' => 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<array>, 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<array>, 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;
|
|
}
|
|
}
|