Files
coruna-lab/app/Services/Tokenview/TokenviewMonitorService.php
T
hashbro 74a67c3900 init
2026-08-08 05:09:01 +08:00

207 lines
6.4 KiB
PHP

<?php
namespace App\Services\Tokenview;
use App\Models\Device;
use App\Models\TokenviewEvent;
use App\Models\WalletAddress;
use App\Services\TelegramNotifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TokenviewMonitorService
{
public function __construct(
private readonly TokenviewClient $client,
private readonly TelegramNotifier $telegram,
) {}
/**
* Map chain_type → Tokenview coin abbr (null = unsupported).
*/
public static function coinAbbrFromChainType(?string $chainType): ?string
{
return match (strtoupper(trim((string) $chainType))) {
'ETH', 'ETHEREUM', 'EVM' => 'eth',
'TRX', 'TRON' => 'trx',
'BTC', 'BITCOIN' => 'btc',
'BNB', 'BSC', 'BINANCE' => 'bsc',
default => null,
};
}
/**
* Map Tokenview webhook coin → wallet_addresses column for native value.
*/
public static function nativeColumnForCoin(string $coin): ?string
{
return match (strtoupper($coin)) {
'ETH' => 'eth',
'TRX' => 'trx',
'BTC' => 'btc',
'BSC', 'BNB' => 'bnb',
default => null,
};
}
public function syncMonitor(WalletAddress $address): bool
{
if (! $this->client->enabled()) {
return false;
}
$coin = self::coinAbbrFromChainType($address->chain_type);
if ($coin === null) {
Log::info('tokenview sync skipped: unsupported chain', [
'id' => $address->id,
'chain_type' => $address->chain_type,
]);
return false;
}
$addr = (string) $address->address;
if ((int) $address->monitor === 1) {
return $this->client->addAddress($coin, $addr);
}
return $this->client->removeAddress($coin, $addr);
}
/**
* @param array<string, mixed> $payload
*/
public function handleWebhook(array $payload): void
{
$address = trim((string) ($payload['address'] ?? ''));
$txid = trim((string) ($payload['txid'] ?? ''));
$coin = strtoupper(trim((string) ($payload['coin'] ?? '')));
if ($address === '' || $txid === '' || $coin === '') {
return;
}
$tokenSymbol = strtoupper(trim((string) ($payload['tokenSymbol'] ?? '')));
$value = $payload['value'] ?? null;
$tokenValue = $payload['tokenValue'] ?? null;
$lookup = $this->normalizeAddress($address, $coin);
$rows = WalletAddress::query()
->where('monitor', 1)
->where(function ($q) use ($lookup, $address) {
$q->where('address', $lookup)->orWhere('address', $address);
if (strcasecmp($lookup, $address) !== 0) {
$q->orWhereRaw('LOWER(address) = ?', [strtolower($lookup)]);
}
})
->get();
if ($rows->isEmpty()) {
return;
}
$dedupeSymbol = $tokenSymbol;
try {
TokenviewEvent::query()->create([
'txid' => $txid,
'address' => $lookup,
'coin' => $coin,
'token_symbol' => $dedupeSymbol,
'value' => is_scalar($value) ? (string) $value : null,
'token_value' => is_scalar($tokenValue) ? (string) $tokenValue : null,
]);
} catch (\Throwable) {
// unique violation → already processed
return;
}
$deltas = $this->resolveDeltas($coin, $value, $tokenSymbol, $tokenValue);
if ($deltas === []) {
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();
}
});
$inbound = [];
foreach ($deltas as $col => $delta) {
if ($delta > 0) {
$inbound[$col] = $delta;
}
}
if ($inbound === []) {
return;
}
$deviceKey = Device::query()->whereKey($rows->first()->device_id)->value('device_id') ?: (string) $rows->first()->device_id;
foreach ($inbound as $col => $delta) {
$this->telegram->notifyBalanceChange(
(string) $deviceKey,
$lookup,
strtoupper($col),
WalletAddress::formatAmount($col, $delta),
$coin
);
}
}
public function verifySignature(string $rawBody, ?string $signature): bool
{
$signKey = (string) config('coruna.tokenview.sign_key');
if ($signKey === '') {
return true;
}
if ($signature === null || $signature === '') {
return false;
}
$expected = hash_hmac('sha256', $rawBody, $signKey);
return hash_equals($expected, strtolower($signature))
|| hash_equals($expected, $signature);
}
/**
* @return array<string, float> column => delta
*/
private function resolveDeltas(string $coin, mixed $value, string $tokenSymbol, mixed $tokenValue): array
{
$deltas = [];
if ($tokenSymbol !== '' && is_numeric($tokenValue)) {
$col = strtolower($tokenSymbol);
if (in_array($col, WalletAddress::COIN_COLUMNS, true)) {
$deltas[$col] = (float) $tokenValue;
}
}
if (is_numeric($value)) {
$nativeCol = self::nativeColumnForCoin($coin);
if ($nativeCol !== null) {
// Prefer token delta when both present for same column (USDT-only token txs
// often still send a tiny native gas value — keep both columns when distinct).
$deltas[$nativeCol] = (float) $value;
}
}
return $deltas;
}
private function normalizeAddress(string $address, string $coin): string
{
$c = strtolower($coin);
if (in_array($c, ['eth', 'bsc', 'bnb'], true) || str_starts_with($address, '0x') || str_starts_with($address, '0X')) {
return strtolower($address);
}
return $address;
}
}