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')); } }