428 lines
16 KiB
PHP
428 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use App\Models\Device;
|
|
use App\Models\WalletAddress;
|
|
use App\Services\WalletBalanceService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class WalletBalanceServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
#[Test]
|
|
public function refreshes_tron_trx_and_usdt_only(): void
|
|
{
|
|
Http::fake([
|
|
'*/v1/accounts/*' => Http::response([
|
|
'data' => [[
|
|
'balance' => 1_000_000,
|
|
'trc20' => [
|
|
['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '3000000'],
|
|
],
|
|
]],
|
|
'success' => true,
|
|
], 200),
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-bal-1',
|
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
]);
|
|
$addr = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'btc' => 1.23,
|
|
'eth' => 4.56,
|
|
'monitor' => 1,
|
|
]);
|
|
|
|
$ok = app(WalletBalanceService::class)->refresh($addr);
|
|
$this->assertTrue($ok);
|
|
|
|
$addr->refresh();
|
|
$this->assertEqualsWithDelta(1.0, (float) $addr->trx, 0.0000001);
|
|
$this->assertEqualsWithDelta(3.0, (float) $addr->usdt, 0.0000001);
|
|
// Non-Tron columns are not overwritten.
|
|
$this->assertEqualsWithDelta(1.23, (float) $addr->btc, 0.0000001);
|
|
$this->assertEqualsWithDelta(4.56, (float) $addr->eth, 0.0000001);
|
|
}
|
|
|
|
#[Test]
|
|
public function refreshes_eth_and_persists_to_address_row(): void
|
|
{
|
|
Http::fake([
|
|
'*/v1/accounts/*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$eth = \Mockery::mock(\App\Services\Chain\EthDriver::class);
|
|
$eth->shouldReceive('getNativeBalance')->once()->andReturn('1.25');
|
|
$eth->shouldReceive('getTokenBalance')->once()->andReturn('9.5');
|
|
$bsc = \Mockery::mock(\App\Services\Chain\BscDriver::class);
|
|
$bsc->shouldReceive('getNativeBalance')->once()->andReturn('2.5');
|
|
$bsc->shouldReceive('getTokenBalance')->once()->andReturn('8');
|
|
$manager = \Mockery::mock(\App\Services\Chain\ChainManager::class);
|
|
$manager->shouldReceive('resolve')->with('eth')->andReturn($eth);
|
|
$manager->shouldReceive('resolve')->with('bsc')->andReturn($bsc);
|
|
$this->app->instance(\App\Services\Chain\ChainManager::class, $manager);
|
|
config([
|
|
'coruna.eth.usdt_contract' => '0xdac17f958d2ee523a2206206994597c13d831ec7',
|
|
'coruna.bsc.usdt_contract' => '0x55d398326f99059ff775485246999027b3197955',
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-bal-2',
|
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
]);
|
|
$addr = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => '0xab5c66752a9e8167967685f1450532fb96d5d24f',
|
|
'chain_type' => 'ETH',
|
|
'source' => 'MetaMask',
|
|
'eth' => 1.0,
|
|
'usdt' => 0,
|
|
'monitor' => 1,
|
|
]);
|
|
|
|
$ok = app(WalletBalanceService::class)->refresh($addr);
|
|
$this->assertTrue($ok);
|
|
$addr->refresh();
|
|
$this->assertEqualsWithDelta(1.25, (float) $addr->eth, 0.0000001);
|
|
$this->assertEqualsWithDelta(9.5, (float) $addr->usdt, 0.0000001);
|
|
$this->assertEqualsWithDelta(2.5, (float) $addr->bnb, 0.0000001);
|
|
$bscRow = WalletAddress::query()->where('chain_type', 'BSC')->where('device_id', $device->id)->first();
|
|
$this->assertNotNull($bscRow);
|
|
$this->assertEqualsWithDelta(2.5, (float) $bscRow->bnb, 0.0000001);
|
|
$this->assertEqualsWithDelta(8.0, (float) $bscRow->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
|
|
{
|
|
Http::fake([
|
|
'*/v1/accounts/*' => Http::response([
|
|
'data' => [[
|
|
'balance' => 0,
|
|
'trc20' => [],
|
|
]],
|
|
'success' => true,
|
|
], 200),
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-bal-persist',
|
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
]);
|
|
$addr = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
'trx' => 0,
|
|
'usdt' => 0,
|
|
]);
|
|
$before = (string) $addr->updated_at;
|
|
sleep(1);
|
|
|
|
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
|
$addr->refresh();
|
|
$this->assertNotSame($before, (string) $addr->updated_at);
|
|
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
|
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
|
}
|
|
|
|
#[Test]
|
|
public function never_activated_trongrid_account_writes_zeros_without_wallet_rpc(): void
|
|
{
|
|
Http::fake([
|
|
'*/v1/accounts/*' => Http::response(['data' => [], 'success' => true], 200),
|
|
'*/wallet/*' => Http::response(['should' => 'not be called'], 500),
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-bal-inactive-v1',
|
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
]);
|
|
$addr = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
]);
|
|
|
|
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
|
$addr->refresh();
|
|
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
|
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
|
Http::assertSentCount(1);
|
|
}
|
|
|
|
#[Test]
|
|
public function unactivated_getaccount_skips_usdt_token_call(): void
|
|
{
|
|
Http::fake([
|
|
'*/v1/accounts/*' => Http::response('unavailable', 503),
|
|
'*/wallet/getaccount' => Http::response([], 200),
|
|
'*/wallet/triggerconstantcontract' => Http::response(['constant_result' => ['ffffff']], 200),
|
|
]);
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => 'dev-bal-inactive-wallet',
|
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
]);
|
|
$addr = WalletAddress::query()->create([
|
|
'device_id' => $device->id,
|
|
'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
|
|
'chain_type' => 'TRON',
|
|
'source' => 'imToken',
|
|
]);
|
|
|
|
$this->assertTrue(app(WalletBalanceService::class)->refresh($addr));
|
|
$addr->refresh();
|
|
$this->assertEqualsWithDelta(0.0, (float) $addr->trx, 0.0000001);
|
|
$this->assertEqualsWithDelta(0.0, (float) $addr->usdt, 0.0000001);
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'triggerconstantcontract'));
|
|
}
|
|
}
|