fix: balance
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
|
||||
if ($source !== '') {
|
||||
$lines[] = '🏷 <b>Source</b>: '.$this->e($source);
|
||||
}
|
||||
$lines[] = '📬 <b>Address</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
|
||||
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\TelegramNotifier;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WalletAddressDisplayTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function tron_balance_display_includes_zeros(): void
|
||||
{
|
||||
$addr = new WalletAddress([
|
||||
'chain_type' => '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</b>: imToken')
|
||||
&& str_contains($text, 'Balance</b>: TRX: 0 USDT: 0')
|
||||
&& ! str_contains($text, 'Balance</b>: imToken');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user