*/ 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|null $balance * @return array */ 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 */ 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 */ public function coinSnapshot(): array { $out = []; foreach (self::COIN_COLUMNS as $col) { $v = $this->{$col}; $out[$col] = $v === null || $v === '' ? null : (string) $v; } return $out; } }