Files
coruna-lab/app/Models/WalletAddress.php
T
root 263fd917ac fix(wallet): clamp negative balances to 0 at ingest and display
Device-reported balances (e.g. Trust Wallet after a sweep) could carry
negative values that were stored verbatim by coinAttributesFromBalance
(only is_numeric was checked). BTC/ETH/BSC/SOL are not auto-refreshed
after ingest (only Tron is), so the negative persisted in the DB and
rendered in the UI. Clamp negatives to 0 at ingest and in formatAmount
so stale device-reported negatives never display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 20:40:08 +00:00

186 lines
5.2 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletAddress extends Model
{
/** @var list<string> */
public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb', 'sol'];
/** Display decimal places per coin (UI). */
public const DISPLAY_DECIMALS = [
'usdt' => 2,
'trx' => 6,
'eth' => 6,
'btc' => 8,
'bnb' => 6,
'sol' => 6,
];
protected $fillable = [
'device_id', 'address', 'chain_type',
'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol',
'monitor', 'monitor_synced', 'monitor_failures', 'source', 'mnemonic_id', 'derive_index',
];
protected function casts(): array
{
return [
'monitor' => 'integer',
'monitor_synced' => 'boolean',
'monitor_failures' => 'integer',
'derive_index' => 'integer',
'usdt' => 'decimal:6',
'trx' => 'decimal:6',
'eth' => 'decimal:18',
'btc' => 'decimal:8',
'bnb' => 'decimal:18',
'sol' => 'decimal:9',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function mnemonic(): BelongsTo
{
return $this->belongsTo(WalletMnemonic::class, 'mnemonic_id');
}
/**
* Map a symbol=>amount payload into coin column attributes (only known coins).
* Missing symbols are omitted (caller merges with existing row).
*
* @param array<string, mixed>|string|null $balance
* @return array<string, string|int|float|null>
*/
public static function coinAttributesFromBalance(mixed $balance): array
{
if (! is_array($balance)) {
return [];
}
$out = [];
foreach ($balance as $symbol => $value) {
$col = strtolower((string) $symbol);
if (! in_array($col, self::COIN_COLUMNS, true)) {
continue;
}
if ($value === null || $value === '') {
$out[$col] = null;
continue;
}
if (! is_numeric($value)) {
continue;
}
// A wallet balance can never be negative; clamp device-reported negatives to 0.
if ((float) $value < 0) {
$out[$col] = '0';
continue;
}
$out[$col] = $value;
}
return $out;
}
public static function displayDecimals(string $coin): int
{
$coin = strtolower($coin);
return self::DISPLAY_DECIMALS[$coin] ?? 6;
}
public static function formatAmount(string $coin, mixed $amount): string
{
if ($amount === null || $amount === '') {
return '';
}
if (! is_numeric($amount)) {
return (string) $amount;
}
// Defensive: never render a negative balance (e.g. stale device-reported rows).
if ((float) $amount < 0) {
$amount = '0';
}
$decimals = self::displayDecimals($coin);
$formatted = number_format((float) $amount, $decimals, '.', '');
$formatted = rtrim(rtrim($formatted, '0'), '.');
return $formatted === '' ? '0' : $formatted;
}
/**
* @return array<string, string>
*/
public function formattedCoins(): array
{
$out = [];
foreach (self::COIN_COLUMNS as $col) {
$out[$col] = self::formatAmount($col, $this->{$col});
}
return $out;
}
public function coinsSummary(): ?string
{
$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 === [] ? 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.
*
* @return array<string, string|null>
*/
public function coinSnapshot(): array
{
$out = [];
foreach (self::COIN_COLUMNS as $col) {
$v = $this->{$col};
$out[$col] = $v === null || $v === '' ? null : (string) $v;
}
return $out;
}
}