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
+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
{