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),
]);