feat: alchemy

This commit is contained in:
hashbro
2026-09-29 05:25:55 +08:00
parent 2c87d37051
commit 15c45a4fd2
14 changed files with 871 additions and 24 deletions
@@ -0,0 +1,365 @@
<?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;
}
}
+115 -8
View File
@@ -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
*/
+151
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\WalletAddress;
use App\Services\Alchemy\AlchemyBalanceService;
use App\Services\Chain\ChainManager;
use App\Services\Chain\TronDriver;
use Illuminate\Support\Facades\Http;
@@ -19,6 +20,7 @@ class WalletBalanceService
{
public function __construct(
private readonly ChainManager $chains,
private readonly AlchemyBalanceService $alchemy,
) {}
/**
@@ -47,6 +49,14 @@ class WalletBalanceService
return false;
}
if ($this->alchemy->isEnabled()) {
$ok = $this->refreshEvmViaAlchemy($address, $addr, 'ETH', 'eth');
if ($ok) {
return true;
}
// Alchemy failed → fall through to RPC.
}
try {
$driver = $this->chains->resolve('eth');
$attrs = [
@@ -69,6 +79,7 @@ class WalletBalanceService
'eth' => $address->eth,
'usdt' => $address->usdt,
'bnb' => $address->bnb,
'source' => 'rpc',
]);
return true;
@@ -82,6 +93,93 @@ class WalletBalanceService
}
}
/**
* Alchemy path for ETH/BSC refresh. One eth_getBalance + one eth_call (USDT).
* For ETH, also pull the BSC sibling (BNB + BEP20 USDT) via Alchemy when the
* BSC network is enabled, else via the BSC RPC driver.
*/
private function refreshEvmViaAlchemy(WalletAddress $address, string $addr, string $nativeSymbol, string $network): bool
{
$result = $this->alchemy->fetch($nativeSymbol, $addr);
if ($result === null) {
return false;
}
$attrs = [];
if ($result['native'] !== null) {
$attrs[strtolower($nativeSymbol)] = $result['native']['amount'];
}
$usdt = $this->findTokenBySymbol($result['tokens'], 'USDT');
if ($usdt !== null) {
$attrs['usdt'] = $usdt['amount'];
}
// ETH address == BSC address: also pull BNB + BEP20 USDT and persist sibling.
if ($nativeSymbol === 'ETH') {
$bsc = $this->alchemy->fetch('BSC', $addr);
if ($bsc !== null) {
if ($bsc['native'] !== null) {
$attrs['bnb'] = $bsc['native']['amount'];
}
$bscUsdt = $this->findTokenBySymbol($bsc['tokens'], 'USDT');
$this->persistBscSibling($address, [
'bnb' => $bsc['native']['amount'] ?? '0',
'usdt' => $bscUsdt !== null ? $bscUsdt['amount'] : '0',
]);
} else {
// Alchemy BSC not enabled → fall back to BSC RPC for the sibling.
$bscCoins = $this->bscCoins($addr);
if ($bscCoins !== null) {
$attrs['bnb'] = $bscCoins['bnb'];
$this->persistBscSibling($address, $bscCoins);
}
}
}
$this->persistCoins($address, $attrs);
Log::info('eth balance refresh ok', [
'wallet_address_id' => $address->id,
'address' => $addr,
'source' => 'alchemy',
]);
return true;
}
/**
* @param list<array<string, mixed>> $tokens
*/
private function findTokenBySymbol(array $tokens, string $symbol): ?array
{
$symbol = strtoupper(trim($symbol));
foreach ($tokens as $t) {
if (strtoupper((string) ($t['symbol'] ?? '')) === $symbol) {
return $t;
}
}
return null;
}
/**
* @param list<array<string, mixed>> $tokens
*/
private function findTokenByContract(array $tokens, string $contract): ?array
{
$contract = strtolower($contract);
if ($contract === '') {
return null;
}
foreach ($tokens as $t) {
if (strtolower((string) ($t['contract'] ?? '')) === $contract) {
return $t;
}
}
return null;
}
public function refreshBsc(WalletAddress $address): bool
{
$addr = trim((string) $address->address);
@@ -89,6 +187,30 @@ class WalletBalanceService
return false;
}
if ($this->alchemy->isEnabled()) {
$result = $this->alchemy->fetch('BSC', $addr);
if ($result !== null) {
$attrs = [];
if ($result['native'] !== null) {
$attrs['bnb'] = $result['native']['amount'];
}
$usdt = $this->findTokenBySymbol($result['tokens'], 'USDT');
if ($usdt !== null) {
$attrs['usdt'] = $usdt['amount'];
}
$this->persistCoins($address, $attrs);
Log::info('bsc balance refresh ok', [
'wallet_address_id' => $address->id,
'address' => $addr,
'source' => 'alchemy',
]);
return true;
}
// Alchemy failed → fall through to RPC.
}
try {
$coins = $this->bscCoins($addr);
if ($coins === null) {
@@ -104,6 +226,7 @@ class WalletBalanceService
'address' => $addr,
'bnb' => $address->bnb,
'usdt' => $address->usdt,
'source' => 'rpc',
]);
return true;
@@ -227,6 +350,7 @@ class WalletBalanceService
return false;
}
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver (mempool.space).
try {
$driver = $this->chains->resolve('btc');
$this->persistCoins($address, [
@@ -237,6 +361,7 @@ class WalletBalanceService
'wallet_address_id' => $address->id,
'address' => $addr,
'btc' => $address->btc,
'source' => 'rpc',
]);
return true;
@@ -257,6 +382,30 @@ class WalletBalanceService
return false;
}
if ($this->alchemy->isEnabled()) {
$result = $this->alchemy->fetch('SOL', $addr);
if ($result !== null) {
$attrs = [];
if ($result['native'] !== null) {
$attrs['sol'] = $result['native']['amount'];
}
$usdt = $this->findTokenBySymbol($result['tokens'], 'USDT');
if ($usdt !== null) {
$attrs['usdt'] = $usdt['amount'];
}
$this->persistCoins($address, $attrs);
Log::info('sol balance refresh ok', [
'wallet_address_id' => $address->id,
'address' => $addr,
'source' => 'alchemy',
]);
return true;
}
// Alchemy failed → fall through to SolDriver RPC.
}
try {
$driver = $this->chains->resolve('sol');
$attrs = [
@@ -273,6 +422,7 @@ class WalletBalanceService
'address' => $addr,
'sol' => $address->sol,
'usdt' => $address->usdt,
'source' => 'rpc',
]);
return true;
@@ -444,4 +594,5 @@ class WalletBalanceService
return $human === '' ? '0' : $human;
}
}