diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index ebcf073..3c788dd 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -905,8 +905,10 @@ class IngestService } $isTron = in_array($chainType, ['TRON', 'TRX'], true); + $isBtc = in_array($chainType, ['BTC', 'BITCOIN'], true); // Tron: client payloads often omit/zero balances — pull TRX/USDT before notify. - if ($isTron && (! $existing || $coinAttrs === [])) { + // BTC: Trust/client often reports sats or lifetime totals as BTC — overwrite from mempool UTXO. + if (($isTron && (! $existing || $coinAttrs === [])) || $isBtc) { $this->balances->refresh($addr); $addr->refresh(); } diff --git a/app/Services/Tokenview/TokenviewMonitorService.php b/app/Services/Tokenview/TokenviewMonitorService.php index 08fea64..b372b4b 100644 --- a/app/Services/Tokenview/TokenviewMonitorService.php +++ b/app/Services/Tokenview/TokenviewMonitorService.php @@ -197,15 +197,16 @@ class TokenviewMonitorService return; } - $tronRows = $rows->filter(function (WalletAddress $row) { - return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true); + $refreshRows = $rows->filter(function (WalletAddress $row) { + return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX', 'BTC', 'BITCOIN'], true); }); $deltaRows = $rows->filter(function (WalletAddress $row) { - return ! in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true); + return ! in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX', 'BTC', 'BITCOIN'], true); }); - // Tron webhooks only carry deltas — refresh TRX/USDT from chain as source of truth. - foreach ($tronRows as $row) { + // Tron/BTC webhooks only carry deltas — refresh from chain as source of truth. + // BTC stored `btc` is often Trust/client sats-or-lifetime totals, not current UTXO. + foreach ($refreshRows as $row) { /** @var WalletAddress $row */ if (! $this->balances->refresh($row)) { $this->applyDeltasToRow($row, $deltas); diff --git a/tests/Feature/TokenviewWebhookTest.php b/tests/Feature/TokenviewWebhookTest.php index b2eed43..bb80503 100644 --- a/tests/Feature/TokenviewWebhookTest.php +++ b/tests/Feature/TokenviewWebhookTest.php @@ -187,6 +187,70 @@ class TokenviewWebhookTest extends TestCase }); } + #[Test] + public function webhook_refreshes_btc_balance_from_chain_instead_of_delta(): void + { + config(['coruna.tokenview.sign_key' => '']); + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, 'mempool.space') && str_contains($url, '/address/')) { + return Http::response([ + 'chain_stats' => [ + 'funded_txo_sum' => 61436, + 'spent_txo_sum' => 0, + 'tx_count' => 1, + ], + 'mempool_stats' => [ + 'funded_txo_sum' => 0, + 'spent_txo_sum' => 0, + 'tx_count' => 0, + ], + ], 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', + 'coruna.btc.api_url' => 'https://mempool.space/api', + ]); + + $addr = $this->seedMonitoredAddress([ + 'address' => 'bc1quqfuefm729n3a793meruf8rlcgys5xl4zphhjc', + 'chain_type' => 'BITCOIN', + 'btc' => 48.86220628, + 'eth' => null, + 'usdt' => null, + ]); + + $payload = [ + 'address' => $addr->address, + 'txid' => 'btc-txid-refresh-1', + 'coin' => 'BTC', + 'value' => '0.00061', + ]; + + $this->postJson('/hooks/tokenview', $payload)->assertOk()->assertSee('ok'); + + $addr->refresh(); + $this->assertEqualsWithDelta(0.00061436, (float) $addr->btc, 0.00000001); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'api.telegram.org')) { + return false; + } + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '余额入账') + && str_contains($text, '+0.00061 BTC') + && str_contains($text, '0.00061436'); + }); + } + #[Test] public function webhook_notifies_tron_outbound_after_chain_refresh(): void {