// Display formatting for the Wallet panels. // // THE MILLISATOSHI RULE. A msat value arrives as a decimal string because 2.1e18 does not fit in a // double. Every msat formatter below works on the digit string — no Number(), no parseInt, no // BigInt round-trip through a float. Widening one to a number would silently corrupt large amounts, // and the corruption would only show up on the one payment that mattered. export type AmountUnit = 'sats' | 'btc'; const SATS_PER_BTC = 100_000_000; /** Thousands separators on a bare digit string, left to right in groups of three from the end. */ function groupDigits(digits: string): string { return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ','); } /** * Split a non-negative digit string by 10^places without ever building a number. * `'1234', 3` → `{ whole: '1', frac: '234' }`. */ function splitByPowerOfTen(digits: string, places: number): { whole: string; frac: string } { const clean = digits.replace(/^0+(?=\d)/, ''); if (places === 0) return { whole: clean, frac: '' }; const padded = clean.padStart(places + 1, '0'); return { whole: padded.slice(0, -places), frac: padded.slice(-places) }; } const trimZeros = (frac: string) => frac.replace(/0+$/, ''); // ── satoshis (numbers) ──────────────────────────────────────────────────────────────────────────── /** Sats with thousands separators. Negative amounts keep their sign — a spend should read as one. */ export function formatSats(sats: number | null | undefined): string { if (sats == null || !Number.isFinite(sats)) return '—'; const neg = sats < 0; return `${neg ? '-' : ''}${groupDigits(String(Math.abs(Math.trunc(sats))))}`; } /** BTC from sats, trailing zeros trimmed but never below two decimals, so amounts stay column-aligned. */ export function formatBtc(sats: number | null | undefined): string { if (sats == null || !Number.isFinite(sats)) return '—'; const neg = sats < 0; const abs = Math.abs(Math.trunc(sats)); const whole = Math.floor(abs / SATS_PER_BTC); const frac = String(abs % SATS_PER_BTC).padStart(8, '0'); const shown = trimZeros(frac).padEnd(2, '0'); return `${neg ? '-' : ''}${groupDigits(String(whole))}.${shown}`; } /** The one entry point the views call, so a unit toggle flips every amount on screen at once. */ export function formatAmount(sats: number | null | undefined, unit: AmountUnit): string { if (sats == null || !Number.isFinite(sats)) return '—'; return unit === 'btc' ? `${formatBtc(sats)} BTC` : `${formatSats(sats)} sats`; } // ── millisatoshis (strings) ─────────────────────────────────────────────────────────────────────── /** True when the string is a decimal integer we can format; anything else is passed through verbatim. */ const isDecimal = (value: string) => /^\d+$/.test(value); /** * A msat string as sats, keeping the sub-sat remainder when there is one. String arithmetic throughout — * see the rule at the top of this file. */ export function formatMsatAsSats(msat: string | null | undefined): string { if (msat == null) return '—'; const neg = msat.startsWith('-'); const digits = neg ? msat.slice(1) : msat; if (!isDecimal(digits)) return msat; const { whole, frac } = splitByPowerOfTen(digits, 3); const remainder = trimZeros(frac); return `${neg ? '-' : ''}${groupDigits(whole)}${remainder ? `.${remainder}` : ''}`; } /** A msat string as BTC — 10^11 msat to the bitcoin. */ export function formatMsatAsBtc(msat: string | null | undefined): string { if (msat == null) return '—'; const neg = msat.startsWith('-'); const digits = neg ? msat.slice(1) : msat; if (!isDecimal(digits)) return msat; const { whole, frac } = splitByPowerOfTen(digits, 11); const shown = trimZeros(frac).padEnd(2, '0'); return `${neg ? '-' : ''}${groupDigits(whole)}.${shown}`; } export function formatMsat(msat: string | null | undefined, unit: AmountUnit): string { if (msat == null) return '—'; return unit === 'btc' ? `${formatMsatAsBtc(msat)} BTC` : `${formatMsatAsSats(msat)} sats`; } /** Sats → msat, for request bodies. Multiplying a string by 1000 is three appended zeros. */ export function satsToMsat(sats: number): string { return `${Math.trunc(Math.abs(sats))}000`; } // ── everything else ─────────────────────────────────────────────────────────────────────────────── /** Unix seconds; 0 and null both mean "never" rather than 1970. */ export function formatTimestamp(unixSeconds: number | null | undefined): string { if (!unixSeconds || unixSeconds <= 0) return '—'; const d = new Date(unixSeconds * 1000); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** A countdown as mm:ss — the unlock window, where the seconds genuinely matter. */ export function formatCountdown(seconds: number | null | undefined): string { if (seconds == null || seconds <= 0) return '0:00'; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${String(s).padStart(2, '0')}`; } /** txids, addresses and pubkeys are unreadable in full and unmistakable at both ends. */ export function truncateMiddle(value: string | null | undefined, head = 10, tail = 8): string { if (!value) return '—'; if (value.length <= head + tail + 1) return value; return `${value.slice(0, head)}…${value.slice(-tail)}`; } export function formatConfirmations(confirmations: number): string { if (confirmations <= 0) return 'unconfirmed'; if (confirmations === 1) return '1 conf'; if (confirmations >= 6) return '6+ confs'; return `${confirmations} confs`; } // `copyToClipboard` used to be re-exported from here, wrapping navigator.clipboard with a try/catch. The // helper in `helpers/clipboard` now does that and more — it falls back to execCommand on an insecure // origin, where navigator.clipboard does not exist at all — so callers import it from there directly. // // Removed rather than left as a pass-through: the wrapper had become `copyToClipboard` calling // `copyToClipboard`, which is a stack overflow on any Wallet copy button. Every re-export is a chance for // exactly that, and this file is about formatting.