feat: Solana chain
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user