Files
lp-terminal_github/src/lib/format.ts
T
labrinyang bca538e7e3 feat: SHEEP CHOICE routing, cross-chain deposits, and any-token ZAP
One commit because the pieces do not compile apart: the shared copy, the tab
shell and the data layer all changed together, and splitting them further would
mean inventing intermediate states that never existed.

SHEEP CHOICE — the terminal's own swap. Quotes come from a solver that splits
one trade across several pools instead of forcing it down a single path, and
returns a ready-to-sign Settler transaction; the UI draws the split leg by leg
and scores every venue against one shared fee-free baseline, so the card that
says it pays most actually does. The Kyber transaction path is gone — Kyber is
read-only USD valuation now, and there is no Kyber calldata to sign.

BRIDGE — deposits from other chains over Relay, Across and the native portal,
priced side by side and sorted by what actually reaches you. No fee on any of
them. In-flight transfers get a countdown and survive a reload.

ZAP — add liquidity holding neither side of the pair; whatever needs swapping
is done in the same flow, with an optional stake-after step. Uniswap V2
liquidity now shows up under POSITIONS.

Swaps in flight are persisted, so a refresh mid-swap no longer loses the
transaction. Pair labels copy their token and pool addresses, and jump to
DexScreener.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:01:52 +08:00

127 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatUnits } from 'viem'
const SUB_DIGITS = '₀₁₂₃₄₅₆₇₈₉'
const subscriptNum = (n: number): string =>
String(n)
.split('')
.map((d) => SUB_DIGITS[Number(d)])
.join('')
/** significant-digit number formatting with thousands separators.
many-zero decimals compress to the common web3 notation: 0.000072051 → 0.0₄72051 */
export function fmtNum(x: number, sig = 5): string {
if (!Number.isFinite(x)) return '—'
if (x === 0) return '0'
sig = Math.max(1, Math.min(sig, 21)) // toPrecision throws outside [1,100]
const neg = x < 0
const a = Math.abs(x)
let s: string
if (a >= 1) {
const intDigits = Math.floor(Math.log10(a)) + 1
const frac = Math.max(0, Math.min(sig - intDigits, 8))
s = a.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: frac })
} else {
s = a.toPrecision(sig)
if (s.includes('e')) {
const exp = Math.ceil(-Math.log10(a))
s = a.toFixed(Math.min(exp + sig, 18))
}
s = s.replace(/\.?0+$/, '')
const zeros = s.match(/^0\.(0{4,})(?=[1-9])/)
if (zeros) s = `0.0${subscriptNum(zeros[1].length)}${s.slice(2 + zeros[1].length)}`
}
return (neg ? '-' : '') + s
}
export function fmtAmount(v: bigint, decimals: number, sig = 5): string {
return fmtNum(Number(formatUnits(v, decimals)), sig)
}
/** exact decimal-point shift for scientific-notation pastes — string math only,
* a float round-trip would corrupt exactly the tiny amounts this serves */
function shiftDecimal(int: string, frac: string, exp: number): string | null {
if (!Number.isFinite(exp) || Math.abs(exp) > 30) return null
const digits = int + frac
const point = int.length + exp
if (point <= 0) return `0.${'0'.repeat(-point)}${digits}`
if (point >= digits.length) return digits + '0'.repeat(point - digits.length)
return `${digits.slice(0, point)}.${digits.slice(point)}`
}
/** normalize user-typed token amounts (the web3 small-amount conventions):
* whitespace stripped, decimal comma → dot, pasted scientific notation
* ("5e-5") expanded exactly, and the fraction CLAMPED to the token's decimals
* — text the token cannot represent must not sit in the box silently quoting
* zero. Returns the normalized string, or null when the text is not an amount
* (caller keeps the previous value, the controlled input swallows the key). */
export function sanitizeAmountInput(raw: string, decimals: number): string | null {
let s = raw
.replace(/\s+/g, '')
// Chinese-IME width normalization: with an IME active the digit/dot keys
// emit fullwidth -9/。/./,— rejecting just the dot turned a typed
// "0。05" into "005", a 100× amount error, so map instead of reject
.replace(/[-]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 0xfee0))
.replace(/[。.]/g, '.')
.replace('', ',')
.replace(',', '.')
if (s === '') return ''
const sci = s.match(/^(\d*)\.?(\d*)[eE]([+-]?\d+)$/)
if (sci && (sci[1] || sci[2])) {
const shifted = shiftDecimal(sci[1], sci[2], Number(sci[3]))
if (shifted === null) return null
s = shifted
}
if (!/^\d*\.?\d*$/.test(s)) return null
const [int, frac = ''] = s.split('.')
if (frac.length > decimals) return decimals === 0 ? int : `${int}.${frac.slice(0, decimals)}`
return s
}
/** compact amount for dense table cells: 24.9M, 338.4K, 12.4, 0.0421.
* Bounded by construction — ~8 characters whatever the input. Intl's compact
* notation stops at T and then silently degrades into a grouped number
* ("298,973,758,851.7T", 18 chars), which is the one thing a fixed-width cell
* cannot absorb: it widened the pool table past the viewport. */
export function fmtCompact(x: number): string {
if (!Number.isFinite(x)) return '—'
if (x !== 0 && Math.abs(x) < 1) return fmtNum(x, 3)
if (Math.abs(x) >= 1e15) return x.toExponential(1)
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(x)
}
export function fmtCompactAmount(v: bigint, decimals: number): string {
return fmtCompact(Number(formatUnits(v, decimals)))
}
export function fmtUsd(x: number | string | undefined): string {
const n = typeof x === 'string' ? Number(x) : x
if (n === undefined || !Number.isFinite(n)) return ''
// bounded width: sub-cent USD precision is noise everywhere in this app,
// and long fractions (dust TVLs) were stretching table columns
if (n > 0 && n < 0.01) return '<$0.01'
// exact dollars are this function's job, but the inputs include third-party
// figures (dexscreener) we don't get to sanity-check: past a trillion the
// number is wrong, and rendering it exactly costs 30 characters of column
if (Math.abs(n) >= 1e12) return '$' + fmtCompact(n)
return '$' + n.toLocaleString('en-US', { maximumFractionDigits: n >= 1000 ? 0 : 2 })
}
export function fmtPct(x: number, dp = 2): string {
if (!Number.isFinite(x)) return '—'
return x.toFixed(dp) + '%'
}
export function shortAddr(a: string): string {
return a.slice(0, 6) + '…' + a.slice(-4)
}
/** signed bps difference of a vs b (positive = a better) */
export function bpsDiff(a: bigint, b: bigint): number {
if (b === 0n) return 0
return Number(((a - b) * 1_000_000n) / b) / 100
}
export function nowSec(): number {
return Math.floor(Date.now() / 1000)
}