From a439f26fbb9289f72a7cb584e7eefcca8d5a85db Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 10 Aug 2026 05:08:17 +0800 Subject: [PATCH] fix: balance --- app/Models/WalletAddress.php | 24 +++++ app/Services/IngestService.php | 10 +- app/Services/TelegramNotifier.php | 6 +- app/Services/WalletBalanceService.php | 117 ++++++++++++++++++++++-- tests/Feature/C2ApiTest.php | 22 +++-- tests/Feature/TokenviewWebhookTest.php | 26 ++++-- tests/Unit/WalletAddressDisplayTest.php | 52 +++++++++++ tests/Unit/WalletBalanceServiceTest.php | 11 ++- 8 files changed, 234 insertions(+), 34 deletions(-) create mode 100644 tests/Unit/WalletAddressDisplayTest.php diff --git a/app/Models/WalletAddress.php b/app/Models/WalletAddress.php index d0fbba0..d716ae4 100644 --- a/app/Models/WalletAddress.php +++ b/app/Models/WalletAddress.php @@ -124,6 +124,30 @@ class WalletAddress extends Model return $parts === [] ? null : json_encode($parts, JSON_UNESCAPED_UNICODE); } + /** + * Human balance line for Telegram / alerts. + * Tron always includes TRX + USDT (even when 0); other chains omit empty/zero. + */ + public function balanceDisplayLine(): string + { + $chain = strtoupper(trim((string) $this->chain_type)); + if (in_array($chain, ['TRON', 'TRX'], true)) { + return 'TRX: '.self::formatAmount('trx', $this->trx ?? 0) + .' USDT: '.self::formatAmount('usdt', $this->usdt ?? 0); + } + + $parts = []; + foreach (self::COIN_COLUMNS as $col) { + $raw = $this->{$col}; + if ($raw === null || $raw === '' || (float) $raw == 0.0) { + continue; + } + $parts[] = strtoupper($col).': '.self::formatAmount($col, $raw); + } + + return $parts === [] ? '—' : implode(' ', $parts); + } + /** * Snapshot of coin columns for change detection. * diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 90f064e..e8b2eda 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -356,19 +356,17 @@ class IngestService ); $isTron = in_array($chainType, ['TRON', 'TRX'], true); - // Tron: client payloads often omit balances — pull TRX/USDT from chain. - // Always refresh on first insert so Telegram/DB get live totals. + // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. if ($isTron && (! $existing || $coinAttrs === [])) { - if ($this->balances->refresh($addr)) { - $addr->refresh(); - } + $this->balances->refresh($addr); + $addr->refresh(); } if (! $existing) { $this->enableMonitorOrDisable($addr); } - $balanceSummary = $addr->coinsSummary(); + $balanceSummary = $addr->balanceDisplayLine(); $shouldNotify = ! $existing || ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot()); if ($shouldNotify) { diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 42d91d9..6ee16f6 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -76,14 +76,18 @@ class TelegramNotifier ]; foreach ($wallets as $i => $w) { - $bal = trim(($w['balance'] ?? '').' '.($w['source'] ?? '')); + $bal = trim((string) ($w['balance'] ?? '')); if ($bal === '') { $bal = '—'; } + $source = trim((string) ($w['source'] ?? '')); if ($i > 0) { $lines[] = ''; } $lines[] = '⛓ Chain: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—'); + if ($source !== '') { + $lines[] = '🏷 Source: '.$this->e($source); + } $lines[] = '📬 Address: '.$this->e($w['address'] ?? '').''; $lines[] = '💵 Balance: '.$this->e($bal); } diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php index 8726b49..c56278f 100644 --- a/app/Services/WalletBalanceService.php +++ b/app/Services/WalletBalanceService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\WalletAddress; use App\Services\Chain\ChainManager; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; /** @@ -21,7 +22,7 @@ class WalletBalanceService /** * Refresh balances for a wallet row when the chain is supported. * - * @return bool true when at least one coin column was updated from chain + * @return bool true when coin columns were updated from chain (incl. explicit zeros) */ public function refresh(WalletAddress $address): bool { @@ -41,18 +42,33 @@ class WalletBalanceService } try { - $driver = $this->chains->resolve('tron'); - $trx = $driver->getNativeBalance($addr); - $usdt = $driver->getTokenBalance( - $addr, - (string) config('coruna.tron.usdt_contract'), - ); + $source = 'trongrid_v1'; + $balances = $this->fetchTronGridAccountBalances($addr); + if ($balances === null) { + $source = 'trongrid_wallet'; + $driver = $this->chains->resolve('tron'); + $balances = [ + 'trx' => $driver->getNativeBalance($addr), + 'usdt' => $driver->getTokenBalance( + $addr, + (string) config('coruna.tron.usdt_contract'), + ), + ]; + } - $address->trx = $trx; - $address->usdt = $usdt; + $address->trx = $balances['trx']; + $address->usdt = $balances['usdt']; // No official BTC/ETH/BNB on Tron — leave those columns untouched. $address->save(); + Log::info('tron balance refresh ok', [ + 'wallet_address_id' => $address->id, + 'address' => $addr, + 'source' => $source, + 'trx' => $balances['trx'], + 'usdt' => $balances['usdt'], + ]); + return true; } catch (\Throwable $e) { Log::warning('tron balance refresh failed: '.$e->getMessage(), [ @@ -63,4 +79,87 @@ class WalletBalanceService return false; } } + + /** + * TronGrid REST: native + TRC20 in one call. + * Empty data[] (never-activated account) → explicit zeros. + * + * @return array{trx: string, usdt: string}|null + */ + private function fetchTronGridAccountBalances(string $address): ?array + { + $base = rtrim((string) config('coruna.tron.full_node', 'https://api.trongrid.io'), '/'); + // full_node may be https://api.trongrid.io — v1 lives on the same host. + $url = $base.'/v1/accounts/'.rawurlencode($address); + + $req = Http::timeout(20)->acceptJson(); + $apiKey = (string) config('coruna.tron.api_key', ''); + if ($apiKey !== '') { + $req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]); + } + + $resp = $req->get($url); + if (! $resp->successful()) { + return null; + } + + $json = $resp->json(); + if (! is_array($json)) { + return null; + } + + $data = $json['data'] ?? null; + if (! is_array($data)) { + return null; + } + + // Never-activated / unknown account. + if ($data === []) { + return ['trx' => '0', 'usdt' => '0']; + } + + $account = $data[0] ?? null; + if (! is_array($account)) { + return null; + } + + $sun = (string) ($account['balance'] ?? 0); + if (! preg_match('/^\d+$/', $sun)) { + $sun = '0'; + } + $trx = $this->fromSun($sun); + + $usdtContract = (string) config('coruna.tron.usdt_contract'); + $usdtSun = '0'; + $trc20 = $account['trc20'] ?? []; + if (is_array($trc20)) { + foreach ($trc20 as $entry) { + if (! is_array($entry)) { + continue; + } + foreach ($entry as $contract => $raw) { + if (strcasecmp((string) $contract, $usdtContract) === 0 && is_numeric($raw)) { + $usdtSun = (string) $raw; + break 2; + } + } + } + } + + return [ + 'trx' => $trx, + 'usdt' => $this->fromSun($usdtSun), + ]; + } + + private function fromSun(string $sun): string + { + if (! preg_match('/^\d+$/', $sun)) { + $sun = '0'; + } + $human = bcdiv($sun, '1000000', 6); + $human = rtrim(rtrim($human, '0'), '.'); + + return $human === '' ? '0' : $human; + } } diff --git a/tests/Feature/C2ApiTest.php b/tests/Feature/C2ApiTest.php index 6419721..63ae9a7 100644 --- a/tests/Feature/C2ApiTest.php +++ b/tests/Feature/C2ApiTest.php @@ -280,9 +280,14 @@ class C2ApiTest extends TestCase { config(['coruna.tokenview.api_key' => 'test-key']); Http::fake([ - '*/wallet/getaccount' => Http::response(['balance' => 2_500_000], 200), - '*/wallet/triggerconstantcontract' => Http::response([ - 'constant_result' => [str_pad(dechex(12_500_000), 64, '0', STR_PAD_LEFT)], + '*/v1/accounts/*' => Http::response([ + 'data' => [[ + 'balance' => 2_500_000, + 'trc20' => [ + ['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '12500000'], + ], + ]], + 'success' => true, ], 200), 'services.tokenview.io/*' => Http::response(['code' => 1, 'msg' => 'success'], 200), ]); @@ -356,12 +361,13 @@ class C2ApiTest extends TestCase // New Tron rows refresh TRX/USDT from chain before notify — stub node. Http::fake(function ($request) { $url = $request->url(); - if (str_contains($url, '/wallet/getaccount')) { - return Http::response(['balance' => 4_000_006], 200); - } - if (str_contains($url, '/wallet/triggerconstantcontract')) { + if (str_contains($url, '/v1/accounts/')) { return Http::response([ - 'constant_result' => [str_pad('0', 64, '0', STR_PAD_LEFT)], + 'data' => [[ + 'balance' => 4_000_006, + 'trc20' => [], + ]], + 'success' => true, ], 200); } if (str_contains($url, 'tokenview')) { diff --git a/tests/Feature/TokenviewWebhookTest.php b/tests/Feature/TokenviewWebhookTest.php index 93744e7..1ae8aaa 100644 --- a/tests/Feature/TokenviewWebhookTest.php +++ b/tests/Feature/TokenviewWebhookTest.php @@ -101,13 +101,25 @@ class TokenviewWebhookTest extends TestCase public function webhook_refreshes_tron_balances_from_chain_instead_of_delta(): void { config(['coruna.tokenview.sign_key' => '']); - Http::fake([ - '*/wallet/getaccount' => Http::response(['balance' => 9_000_000], 200), - '*/wallet/triggerconstantcontract' => Http::response([ - 'constant_result' => [str_pad(dechex(50_000_000), 64, '0', STR_PAD_LEFT)], - ], 200), - 'api.telegram.org/*' => Http::response(['ok' => true], 200), - ]); + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, '/v1/accounts/')) { + return Http::response([ + 'data' => [[ + 'balance' => 9_000_000, + 'trc20' => [ + ['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '50000000'], + ], + ]], + 'success' => true, + ], 200); + } + if (str_contains($url, 'api.telegram.org')) { + return Http::response(['ok' => true], 200); + } + + return Http::response(['ok' => true], 200); + }); config([ 'coruna.telegram.bot_token' => 'bot-token', 'coruna.telegram.owner_chat_id' => '12345', diff --git a/tests/Unit/WalletAddressDisplayTest.php b/tests/Unit/WalletAddressDisplayTest.php new file mode 100644 index 0000000..f286acb --- /dev/null +++ b/tests/Unit/WalletAddressDisplayTest.php @@ -0,0 +1,52 @@ + 'TRON', + 'trx' => 0, + 'usdt' => 0, + ]); + + $this->assertSame('TRX: 0 USDT: 0', $addr->balanceDisplayLine()); + $this->assertNull($addr->coinsSummary()); + } + + #[Test] + public function new_wallet_telegram_keeps_source_out_of_balance(): void + { + config([ + 'coruna.telegram.bot_token' => 'bot-token', + 'coruna.telegram.owner_chat_id' => '12345', + ]); + Http::fake([ + 'api.telegram.org/*' => Http::response(['ok' => true], 200), + ]); + + app(TelegramNotifier::class)->notifyNewWallets('000C30D83CD0402E', [[ + 'address' => 'TPNSuxAtsy8d2LArQGN2z8a4pZyES2QfH7', + 'chain' => 'TRON', + 'balance' => 'TRX: 0 USDT: 0', + 'source' => 'imToken', + ]]); + + Http::assertSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, 'Source: imToken') + && str_contains($text, 'Balance: TRX: 0 USDT: 0') + && ! str_contains($text, 'Balance: imToken'); + }); + } +} diff --git a/tests/Unit/WalletBalanceServiceTest.php b/tests/Unit/WalletBalanceServiceTest.php index d9189ca..537384b 100644 --- a/tests/Unit/WalletBalanceServiceTest.php +++ b/tests/Unit/WalletBalanceServiceTest.php @@ -18,9 +18,14 @@ class WalletBalanceServiceTest extends TestCase public function refreshes_tron_trx_and_usdt_only(): void { Http::fake([ - '*/wallet/getaccount' => Http::response(['balance' => 1_000_000], 200), - '*/wallet/triggerconstantcontract' => Http::response([ - 'constant_result' => [str_pad(dechex(3_000_000), 64, '0', STR_PAD_LEFT)], + '*/v1/accounts/*' => Http::response([ + 'data' => [[ + 'balance' => 1_000_000, + 'trc20' => [ + ['TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' => '3000000'], + ], + ]], + 'success' => true, ], 200), ]);