+ }> navigate(APP_ROUTES.dashboard)} />} /> - } /> + void refreshBalances(), + }} + costPerCall={0.08} + callsPerDay={120} + openDeposit={openDeposit} + /> + } + /> } /> } /> @@ -740,13 +848,29 @@ function App() {
Current vault balance - {formatUsdc(vaultBalance)} USDC + void refreshBalances()} + pending={vaultPending} + />

Funds are used for call routing, model execution, and premium features.

Wallet available - {formatUsdc(walletBalance)} USDC + void refreshBalances()} + placeholder="Connect wallet" + />

Deposits settle on Stellar. Network fee is shown before wallet approval.

@@ -972,11 +1096,27 @@ function App() {
Vault balance - {formatUsdc(vaultBalance)} USDC + void refreshBalances()} + pending={vaultPending} + />
Wallet available - {formatUsdc(walletBalance)} USDC + void refreshBalances()} + placeholder="Connect wallet" + />
@@ -997,7 +1137,13 @@ function App() { aria-invalid={validationMessage.length > 0 && depositStage === "input"} /> USDC - @@ -1051,12 +1197,12 @@ function App() {
Current balance - {formatUsdc(previewCurrentBalance)} USDC + {previewVaultStroops === null ? "--" : `${formatUsdcStroops(previewVaultStroops)} USDC`}
New balance - {hasAmount || submittedAmount ? `${formatUsdc(projectedBalance)} USDC` : "--"} + {projectedVaultStroops === null ? "--" : `${formatUsdcStroops(projectedVaultStroops)} USDC`}
@@ -1099,7 +1245,9 @@ function App() { {depositStage === "confirmed" && (
Deposit successful -

Your updated vault balance is {formatUsdc(vaultBalance)} USDC and ready for usage.

+

+ {vaultText ? `Your updated vault balance is ${vaultText} USDC` : "Your vault balance is being refreshed."} and ready for usage. +

)}
diff --git a/src/components/BalanceReadout.css b/src/components/BalanceReadout.css new file mode 100644 index 00000000..bfffa002 --- /dev/null +++ b/src/components/BalanceReadout.css @@ -0,0 +1,63 @@ +/* BalanceReadout — one on-chain balance with loading / error / not-configured + states. Sizes mirror the `.vault-balance-card strong` / `.balance-tile + strong` typography (1.35rem) so the skeleton keeps the card height stable. */ + +.balance-readout { + display: block; +} + +.balance-readout--muted { + color: var(--muted); + font-size: 1.35rem; + font-weight: 600; +} + +.balance-readout--loading { + display: flex; + align-items: center; + min-height: 1.6em; +} + +.balance-readout--loading .skeleton { + display: inline-block; + max-width: 100%; +} + +.balance-readout--error { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: var(--danger, #ef4444); + font-size: 0.9rem; + font-weight: 500; + line-height: 1.4; +} + +.balance-readout__error-text { + overflow-wrap: anywhere; +} + +.balance-readout__retry { + color: var(--danger, #ef4444); + font-size: 0.85rem; + padding: 2px 8px; +} + +.balance-readout__pending { + display: inline-block; + margin-left: 8px; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-soft); + color: var(--muted); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.02em; + vertical-align: middle; +} + +[data-theme="light"] .balance-readout--error { + color: var(--danger, #b91c1c); +} diff --git a/src/components/BalanceReadout.tsx b/src/components/BalanceReadout.tsx new file mode 100644 index 00000000..60027292 --- /dev/null +++ b/src/components/BalanceReadout.tsx @@ -0,0 +1,110 @@ +import Skeleton from "./Skeleton"; +import type { BalanceReadStatus } from "../hooks/useVaultBalances"; +import "./BalanceReadout.css"; + +export type { BalanceReadStatus }; + +export interface BalanceReadoutProps { + /** Formatted USDC amount without the " USDC" suffix. Null while unknown. */ + value: string | null; + /** Read lifecycle shared by the billing surface, modal and dashboard. */ + status: BalanceReadStatus; + /** False when no vault contract is configured; nothing can be read. */ + configured?: boolean; + /** Failure message shown with a Retry action. */ + error?: string | null; + /** Retry handler for the failed read. */ + onRetry?: () => void; + /** Text used when there is no value and no failure, e.g. "Connect wallet". */ + placeholder?: string; + /** Accessible name for this balance, e.g. "Vault balance". */ + label: string; + /** Marks an optimistic value that a chain re-read has not confirmed yet. */ + pending?: boolean; + className?: string; +} + +/** + * Renders one on-chain balance with explicit loading, error, and + * not-configured states. + * + * - Values are formatted upstream (stroops → exact string) so nothing here + * converts money through `Number`. + * - Loading uses a fixed-size skeleton marked `aria-busy` so the surrounding + * card never shifts while the read is in flight. + * - Failures show the message plus a Retry button and never render `0` as if + * it were a real balance. + */ +export default function BalanceReadout({ + value, + status, + configured = true, + error = null, + onRetry, + placeholder, + label, + pending = false, + className, +}: BalanceReadoutProps) { + const classes = ["balance-readout", className].filter(Boolean).join(" "); + + if (!configured) { + return ( + + Vault not configured + + ); + } + + if (value !== null) { + return ( + + {value} USDC + {pending && ( + pending refresh + )} + + ); + } + + if (error) { + return ( +
+ {error} + {onRetry && ( + + )} +
+ ); + } + + if (status === "loading" || status === "idle") { + return ( + + Loading {label} + + + ); + } + + return ( + + {placeholder ?? "—"} + + ); +} + +function toTestId(label: string): string { + return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} diff --git a/src/config/constants.ts b/src/config/constants.ts index 9a55392c..20e22093 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -134,11 +134,22 @@ export const API_BASE = normalizeBaseUrl( runtimeEnv.VITE_API_BASE_URL as string | undefined, ); +/** Human-readable labels for each supported Stellar network. */ +export const STELLAR_NETWORK_LABELS: Record = { + mainnet: "Mainnet", + testnet: "Testnet", + futurenet: "Futurenet", +}; + /** Stellar network the UI is currently pointed at. */ export const STELLAR_NETWORK: StellarNetwork = resolveStellarNetwork( runtimeEnv.VITE_STELLAR_NETWORK as string | undefined, ); +/** Human-readable label for the configured network (e.g. "Testnet"). */ +export const STELLAR_NETWORK_LABEL = + STELLAR_NETWORK_LABELS[STELLAR_NETWORK]; + // ─── Stellar wallet deposits ───────────────────────────────────────────────── // Declared after `STELLAR_NETWORK` because they derive from it. diff --git a/src/hooks/useVaultBalances.ts b/src/hooks/useVaultBalances.ts new file mode 100644 index 00000000..ed21d91e --- /dev/null +++ b/src/hooks/useVaultBalances.ts @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + isVaultReadConfigured, + readVaultBalance, + readWalletUsdcBalance, +} from "../services/vaultReads"; + +/** + * Lifecycle of the on-chain balance reads. + * + * - `idle` — nothing has been requested yet. + * - `loading`— a read is in flight and no value is available to show. + * - `ready` — the most recent read succeeded (`updatedAt` marks which one). + * - `error` — the most recent read failed; `error` explains why. + */ +export type BalanceReadStatus = "idle" | "loading" | "ready" | "error"; + +/** Alias kept for readability at the call sites that predate the split. */ +export type VaultBalanceStatus = BalanceReadStatus; + +export interface VaultBalances { + /** Vault balance in stroops, or null while unknown. */ + vault: bigint | null; + /** Wallet USDC balance in stroops; null until a Freighter address is known. */ + wallet: bigint | null; + status: BalanceReadStatus; + /** Human-readable reason for the most recent failure, if any. */ + error: string | null; + /** False when no vault contract is configured; no read can run. */ + configured: boolean; + /** Re-read both balances. Resolves once the (debounced) read settles. */ + refresh: () => Promise; + /** Epoch ms of the last successful read, or null. */ + updatedAt: number | null; + /** Increments on every successful read; use to detect fresh values. */ + revision: number; +} + +export interface UseVaultBalancesOptions { + /** Connected Freighter address. The wallet read only runs once this is known. */ + address?: string; +} + +interface BalanceState { + vault: bigint | null; + wallet: bigint | null; + status: VaultBalanceStatus; + error: string | null; + updatedAt: number | null; + revision: number; +} + +const INITIAL_STATE: BalanceState = { + vault: null, + wallet: null, + status: "idle", + error: null, + updatedAt: null, + revision: 0, +}; + +/** + * Coalesce bursts of `refresh()` calls (for example when the deposit modal is + * opened and closed) so the RPC sees at most one read per window. + */ +export const BALANCE_REFRESH_DEBOUNCE_MS = 300; + +function errorMessage(reason: unknown): string { + if (reason instanceof Error && reason.message) return reason.message; + if (typeof reason === "string" && reason) return reason; + return "Could not read the on-chain balance."; +} + +/** + * Read the Callora vault and connected-wallet USDC balances from Stellar via + * read-only Soroban simulation. + * + * Values are stroops (`i128`, 7 decimals); format them with + * `formatUsdcStroops` rather than converting through `Number`. Both balances + * share one status so a single Retry recovers the surface, and superseded + * responses are ignored so an older read can never overwrite a newer one. + */ +export function useVaultBalances( + options: UseVaultBalancesOptions = {}, +): VaultBalances { + const address = options.address; + const configured = isVaultReadConfigured(); + const [state, setState] = useState(INITIAL_STATE); + + const versionRef = useRef(0); + const inFlightRef = useRef(false); + const queuedRef = useRef(false); + const timerRef = useRef | null>(null); + const addressRef = useRef(address); + const mountedRef = useRef(true); + const refreshResolversRef = useRef void>>([]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const flushRefreshResolvers = useCallback(() => { + const resolvers = refreshResolversRef.current; + refreshResolversRef.current = []; + for (const resolve of resolvers) resolve(); + }, []); + + const load = useCallback(async () => { + if (!isVaultReadConfigured()) { + flushRefreshResolvers(); + return; + } + // A read is already running: queue exactly one follow-up with the latest + // address instead of stacking another RPC round-trip. + if (inFlightRef.current) { + queuedRef.current = true; + return; + } + + inFlightRef.current = true; + const version = ++versionRef.current; + setState((prev) => ({ + ...prev, + // Keep showing known values while a refresh re-reads them; only show + // skeletons when there is nothing to display yet. + status: prev.vault === null && prev.wallet === null ? "loading" : prev.status, + error: null, + })); + + try { + const knownAddress = addressRef.current; + const [vaultResult, walletResult] = await Promise.allSettled([ + readVaultBalance(knownAddress), + knownAddress ? readWalletUsdcBalance(knownAddress) : Promise.resolve(null), + ]); + if (version !== versionRef.current) return; // superseded: ignore + + const failures: string[] = []; + let vault: bigint | null = null; + let wallet: bigint | null = null; + + if (vaultResult.status === "fulfilled") { + vault = vaultResult.value; + } else { + failures.push(errorMessage(vaultResult.reason)); + } + + if (walletResult.status === "rejected") { + failures.push(errorMessage(walletResult.reason)); + } else if (walletResult.value !== null) { + wallet = walletResult.value; + } + + setState((prev) => ({ + vault, + wallet, + status: failures.length > 0 ? "error" : "ready", + error: failures.length > 0 ? failures[0] : null, + updatedAt: failures.length > 0 ? prev.updatedAt : Date.now(), + revision: failures.length > 0 ? prev.revision : prev.revision + 1, + })); + } catch (error) { + if (version !== versionRef.current) return; + setState((prev) => ({ + ...prev, + vault: null, + wallet: null, + status: "error", + error: errorMessage(error), + })); + } finally { + inFlightRef.current = false; + // Run a queued follow-up even when this response was superseded (for + // example the address became known mid-read), but never after unmount. + const runQueued = queuedRef.current && mountedRef.current; + queuedRef.current = false; + if (runQueued) { + await loadRef.current(); + return; + } + flushRefreshResolvers(); + } + }, [flushRefreshResolvers]); + + const loadRef = useRef(load); + loadRef.current = load; + + useEffect(() => { + addressRef.current = address; + }, [address]); + + // Initial load, and a reload once a Freighter address becomes known so the + // wallet balance can be read. Cleanup invalidates in-flight responses. + useEffect(() => { + if (!configured) return undefined; + void loadRef.current(); + return () => { + versionRef.current += 1; + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [configured, address]); + + useEffect( + () => () => { + flushRefreshResolvers(); + }, + [flushRefreshResolvers], + ); + + const refresh = useCallback((): Promise => { + if (!isVaultReadConfigured()) return Promise.resolve(); + return new Promise((resolve) => { + refreshResolversRef.current.push(resolve); + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + void load(); + }, BALANCE_REFRESH_DEBOUNCE_MS); + }); + }, [load]); + + return { + vault: state.vault, + wallet: state.wallet, + status: state.status, + error: state.error, + configured, + refresh, + updatedAt: state.updatedAt, + revision: state.revision, + }; +} diff --git a/src/pages/DashboardOverview.tsx b/src/pages/DashboardOverview.tsx index 6285f030..b022bc23 100644 --- a/src/pages/DashboardOverview.tsx +++ b/src/pages/DashboardOverview.tsx @@ -20,17 +20,31 @@ import UsageGauge from '../components/UsageGauge'; import Skeleton from '../components/Skeleton'; import EmptyState from '../components/EmptyState'; import PreviewCard, { type PreviewCardData } from '../components/PreviewCard'; -import { formatUsdc, formatPrice, formatTimeString } from '../utils/format'; +import BalanceReadout from '../components/BalanceReadout'; +import { formatUsdc, formatPrice, formatTimeString, formatUsdcStroops } from '../utils/format'; import { usePinnedApis, pinnedApisStore } from '../state/pinnedApis'; import { useAccountId } from '../hooks/useAccount'; import { useRecentActivity } from '../hooks/useRecentActivity'; +import { STELLAR_NETWORK_LABEL } from '../config/constants'; import MOCK_APIS from '../data/mockApis'; import '../components/Dashboard.css'; +import type { BalanceReadStatus } from '../hooks/useVaultBalances'; + +export interface DashboardBalancesView { + vaultText: string | null; + walletText: string | null; + status: BalanceReadStatus; + error: string | null; + configured: boolean; + walletConnected: boolean; + pending: boolean; + onRetry: () => void; +} export interface DashboardOverviewProps { - /** Current Vault balance in USDC */ + /** Current Vault balance in USDC (fallback for legacy callers) */ vaultBalance?: number; - /** Available Wallet balance in USDC */ + /** Available Wallet balance in USDC (fallback for legacy callers) */ walletBalance?: number; /** Average cost per call in USDC */ costPerCall?: number; @@ -40,6 +54,8 @@ export interface DashboardOverviewProps { openDeposit?: (presetAmount?: number) => void; /** Optional custom class name */ className?: string; + /** Chain-sourced balance read state from useVaultBalances. When present, the balance cards render from it (exact text, loading/error/retry). */ + balances?: DashboardBalancesView; } /** Re-exported for backwards compat; canonical definition lives in useRecentActivity. */ @@ -52,6 +68,7 @@ export function DashboardOverview({ callsPerDay = 120, openDeposit = () => {}, className = '', + balances, }: DashboardOverviewProps) { const navigate = useNavigate(); const accountId = useAccountId(); @@ -70,6 +87,14 @@ export function DashboardOverview({ [pinnedApiIds], ); + // Determine values for math (gauge, runway, banner) — prefer chain values when available + const vaultBalanceForMath = balances?.vaultText !== null && balances?.vaultText !== undefined + ? parseFloat(balances.vaultText.replace(/,/g, '')) + : vaultBalance; + const walletBalanceForMath = balances?.walletText !== null && balances?.walletText !== undefined + ? parseFloat(balances.walletText.replace(/,/g, '')) + : walletBalance; + // Vault preview data const vaultPreviewData: PreviewCardData = { id: 'vault-overview', @@ -77,10 +102,10 @@ export function DashboardOverview({ subtitle: 'Callora Stellar Vault Settlement Account', description: 'Pre-funded reserve for automatic API micropayments across subscribed services.', metrics: [ - { label: 'Available', value: `${formatUsdc(vaultBalance)} USDC` }, - { label: 'Est. Runway', value: `${Math.round(vaultBalance / (costPerCall * callsPerDay))} days` }, + { label: 'Available', value: balances ? (balances.vaultText ? `${balances.vaultText} USDC` : '—') : `${formatUsdc(vaultBalance)} USDC` }, + { label: 'Est. Runway', value: vaultBalanceForMath > 0 && costPerCall * callsPerDay > 0 ? `${Math.round(vaultBalanceForMath / (costPerCall * callsPerDay))} days` : '—' }, ], - status: vaultBalance < 20 ? 'warning' : 'operational', + status: vaultBalanceForMath < 20 ? 'warning' : 'operational', details: { 'Auto-Refill': 'Disabled', 'Settlement Layer': 'Stellar Soroban', @@ -91,10 +116,10 @@ export function DashboardOverview({ const walletPreviewData: PreviewCardData = { id: 'wallet-overview', title: 'Connected Wallet', - subtitle: 'Freighter Stellar Wallet (Public Net)', + subtitle: `Freighter Stellar Wallet (${STELLAR_NETWORK_LABEL})`, description: 'Instant deposit source for refilling Callora Vault reserve balances.', metrics: [ - { label: 'Wallet Balance', value: `${formatUsdc(walletBalance)} USDC` }, + { label: 'Wallet Balance', value: balances ? (balances.walletText ? `${balances.walletText} USDC` : (balances.walletConnected ? '—' : 'Connect wallet')) : `${formatUsdc(walletBalance)} USDC` }, { label: 'Min Deposit', value: '10 USDC' }, ], status: 'operational', @@ -102,21 +127,51 @@ export function DashboardOverview({ return (
- + {balances && balances.configured && balances.vaultText !== null ? ( + + ) : !balances ? ( + + ) : null}
{/* Balance Overview Cards with PreviewCard Wrappers */}

Vault balance

- {formatUsdc(vaultBalance)} USDC + {balances ? ( + + ) : ( + {formatUsdc(vaultBalance)} USDC + )}

Wallet available

- {formatUsdc(walletBalance)} USDC + {balances ? ( + + ) : ( + {formatUsdc(walletBalance)} USDC + )}
@@ -124,7 +179,7 @@ export function DashboardOverview({ >(); + +function dedupe(key: string, run: () => Promise): Promise { + const existing = inflight.get(key); + if (existing) return existing as Promise; + const promise = run().finally(() => { + if (inflight.get(key) === promise) inflight.delete(key); + }); + inflight.set(key, promise); + return promise; +} + +function resolveSource(sourceAddress?: string): string { + return sourceAddress && StrKey.isValidEd25519PublicKey(sourceAddress) + ? sourceAddress + : SIMULATION_SOURCE_ACCOUNT; +} + +function describeError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + return "Unknown error"; +} + +async function simulate( + contractId: string, + func: string, + args: xdr.ScVal[], + sourceAddress?: string, +): Promise { + const transaction = new TransactionBuilder( + new Account(resolveSource(sourceAddress), "0"), + { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }, + ) + .addOperation(new Contract(contractId).call(func, ...args)) + .setTimeout(SIMULATION_TIMEOUT_SECONDS) + .build(); + + let simulation; + try { + simulation = await getServer().simulateTransaction(transaction); + } catch (error) { + throw new VaultReadError( + "NETWORK_ERROR", + `Could not reach Stellar RPC while reading ${func}(): ${describeError(error)}`, + ); + } + + if (rpc.Api.isSimulationError(simulation)) { + throw new VaultReadError( + "SIMULATION_FAILED", + `Simulating ${func}() failed: ${simulation.error}`, + ); + } + if (!rpc.Api.isSimulationSuccess(simulation)) { + throw new VaultReadError( + "SIMULATION_FAILED", + `Simulating ${func}() did not return a result. Check the vault contract id and network.`, + ); + } + // In stellar-sdk v12+, the result is in simulation.result.retval + const simResult = (simulation as any).result; + if (!simResult?.retval) { + throw new VaultReadError( + "BAD_RESPONSE", + `Simulating ${func}() returned no value.`, + ); + } + return simResult.retval; +} + +/** Coerce a decoded `i128` into stroops, rejecting anything else. */ +function expectStroops(value: unknown, context: string): bigint { + let stroops: bigint; + if (typeof value === "bigint") { + stroops = value; + } else if (typeof value === "number" && Number.isSafeInteger(value)) { + stroops = BigInt(value); + } else { + throw new VaultReadError( + "BAD_RESPONSE", + `${context} returned an unexpected value; expected an i128 balance.`, + ); + } + if (stroops < 0n) { + throw new VaultReadError( + "BAD_RESPONSE", + `${context} returned a negative balance.`, + ); + } + return stroops; +} + +function notConfiguredError(): VaultReadError { + return new VaultReadError( + "NOT_CONFIGURED", + "The Callora vault contract is not configured for this network, so on-chain balances cannot be read.", + ); +} + +/** + * Simulate the vault's `balance()` entry point. + * + * @param sourceAddress Connected Freighter address to attach as the simulation + * source; falls back to {@link SIMULATION_SOURCE_ACCOUNT} when unknown. + * @returns The vault balance in stroops (`i128`). + */ +export async function readVaultBalance(sourceAddress?: string): Promise { + if (!isVaultReadConfigured()) throw notConfiguredError(); + return dedupe(`vault-balance:${STELLAR_VAULT_CONTRACT_ID}`, async () => { + const retval = await simulate( + STELLAR_VAULT_CONTRACT_ID, + "balance", + [], + sourceAddress, + ); + return expectStroops(scValToNative(retval), "balance()"); + }); +} + +let usdcTokenAddress: string | null = null; +let usdcTokenInflight: Promise | null = null; + +/** Forget the cached USDC token address and any in-flight read (tests / reset). */ +export function resetVaultReadCaches(): void { + usdcTokenAddress = null; + usdcTokenInflight = null; + inflight.clear(); +} + +/** + * Simulate the vault's `get_usdc_token()` entry point and cache the result for + * the lifetime of the session. + * + * @returns The Stellar Asset Contract address backing the vault's USDC. + */ +export async function readUsdcToken(sourceAddress?: string): Promise { + if (!isVaultReadConfigured()) throw notConfiguredError(); + if (usdcTokenAddress) return usdcTokenAddress; + if (usdcTokenInflight) return usdcTokenInflight; + + const request = (async () => { + const retval = await simulate( + STELLAR_VAULT_CONTRACT_ID, + "get_usdc_token", + [], + sourceAddress, + ); + const token = scValToNative(retval); + if (typeof token !== "string" || !StrKey.isValidContract(token)) { + throw new VaultReadError( + "BAD_RESPONSE", + "get_usdc_token() did not return a Stellar Asset Contract address.", + ); + } + return token; + })(); + + usdcTokenInflight = request; + try { + usdcTokenAddress = await request; + return usdcTokenAddress; + } finally { + usdcTokenInflight = null; + } +} + +/** + * Simulate `balance(address)` on the vault's USDC Stellar Asset Contract. + * + * @param address Account whose USDC balance is read; also used as the + * simulation source. + * @returns The wallet's USDC balance in stroops. + */ +export async function readWalletUsdcBalance(address: string): Promise { + if (!isVaultReadConfigured()) throw notConfiguredError(); + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new VaultReadError( + "BAD_RESPONSE", + "A valid Stellar account address is required to read a wallet USDC balance.", + ); + } + const token = await readUsdcToken(address); + return dedupe(`wallet-balance:${token}:${address}`, async () => { + const retval = await simulate( + token, + "balance", + [new Address(address).toScVal()], + address, + ); + return expectStroops(scValToNative(retval), "USDC balance()"); + }); +} diff --git a/src/utils/format.ts b/src/utils/format.ts index f363dadd..f93a06bc 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -203,6 +203,132 @@ export function normalizeUsdcAmountInput(raw: string): NormalizedUsdcInput { }; } +// ── On-chain stroops ⇄ USDC ─────────────────────────────────────────────────── +// +// Balances read from Soroban arrive as `i128` stroops (10^-7 USDC). Routing +// them through `Number` silently loses precision above 2^53 stroops, so every +// helper below stays in `bigint` until it produces the final decimal string. + +/** Base units in one USDC, as a bigint scale factor (7 decimals). */ +const USDC_SCALE = 10n ** BigInt(USDC_DECIMALS); + +/** + * Parse a USDC amount into stroops without going through `Number`. + * + * Returns `null` when the value is not a plain decimal amount (empty text, + * separators, scientific notation) or carries more than `USDC_DECIMALS` + * fraction digits, so callers can treat `null` as "not a valid amount". + * + * @example usdcAmountToStroops("1260.5") // 12605000000n + * @example usdcAmountToStroops("1.123456789") // null (too many digits) + */ +export function usdcAmountToStroops(amount: string | number): bigint | null { + const text = typeof amount === "number" ? String(amount) : (amount ?? "").trim(); + if (text === "" || text === "." || !/^\d*(?:\.\d*)?$/.test(text)) return null; + + const [whole = "", fraction = ""] = text.split("."); + if (fraction.length > USDC_DECIMALS) return null; + + const wholeStroops = BigInt(whole === "" ? "0" : whole) * USDC_SCALE; + const fractionStroops = BigInt( + (fraction + "0".repeat(USDC_DECIMALS)).slice(0, USDC_DECIMALS) || "0", + ); + return wholeStroops + fractionStroops; +} + +/** + * Exact decimal string for a stroops amount: no grouping, no rounding, and + * trailing fraction zeros trimmed. Safe for any `bigint`, including values + * far above 2^53. + * + * @example stroopsToUsdcAmount(12605000000n) // "1260.5" + * @example stroopsToUsdcAmount(5000000000n) // "500" + */ +export function stroopsToUsdcAmount(stroops: bigint): string { + const negative = stroops < 0n; + const abs = negative ? -stroops : stroops; + const whole = abs / USDC_SCALE; + const fraction = (abs % USDC_SCALE) + .toString() + .padStart(USDC_DECIMALS, "0") + .replace(/0+$/, ""); + return `${negative ? "-" : ""}${whole.toString()}${fraction ? `.${fraction}` : ""}`; +} + +/** + * Group an all-digits integer string with the locale's thousands separator. + * + * `Intl.NumberFormat` (NumberFormat V3) accepts strings and formats them + * exactly; older implementations coerce to `number` and would round large + * values, so the result is verified against the input digits and a plain + * comma-grouped fallback is used when the runtime cannot format exactly. + */ +function groupIntegerDigits(digits: string, locale: string): string { + if (digits.length <= 3) return digits; + try { + const formatted = new Intl.NumberFormat(locale, { + maximumFractionDigits: 0, + useGrouping: true, + }).format(digits as any); + if (formatted.replace(/\D+/g, "") === digits) return formatted; + } catch { + // Fall through to the locale-independent grouping below. + } + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +export interface FormatUsdcStroopsOptions { + /** Locale used for the decimal point and grouping. Defaults to the browser locale. */ + locale?: string; + /** Fraction digits to display. Defaults to 2. Rounded half-up in bigint math. */ + fractionDigits?: number; + /** Whether thousands are grouped. Defaults to true. */ + grouping?: boolean; +} + +/** + * Format a stroops balance as a locale-aware USDC display string without ever + * converting the value to `Number`. + * + * @example formatUsdcStroops(5000000000n) // "500.00" + * @example formatUsdcStroops(12605000000n) // "1,260.50" + * @example formatUsdcStroops(12605000001n) // "1,260.50" (half-up) + * @example formatUsdcStroops(12605000001n, { grouping: false }) // "1260.50" + */ +export function formatUsdcStroops( + stroops: bigint, + options: FormatUsdcStroopsOptions = {}, +): string { + const { locale, grouping = true } = options; + const digits = Math.min(Math.max(Math.trunc(options.fractionDigits ?? 2), 0), 12); + + const negative = stroops < 0n; + const abs = negative ? -stroops : stroops; + + let whole: bigint; + let fractionText: string; + + if (digits >= USDC_DECIMALS) { + // No rounding possible (or requested): keep every stroop and pad. + whole = abs / USDC_SCALE; + fractionText = (abs % USDC_SCALE).toString().padStart(USDC_DECIMALS, "0") + + "0".repeat(digits - USDC_DECIMALS); + } else { + const unit = 10n ** BigInt(USDC_DECIMALS - digits); // stroops per display unit + const rounded = (abs + unit / 2n) / unit; // half-up in bigint math + const divisor = 10n ** BigInt(digits); + whole = rounded / divisor; + const fraction = rounded % divisor; + fractionText = digits === 0 ? "" : fraction.toString().padStart(digits, "0"); + } + + const wholeText = grouping + ? groupIntegerDigits(whole.toString(), resolveLocale(locale)) + : whole.toString(); + const fractionDisplay = digits === 0 ? "" : `.${fractionText}`; + return `${negative ? "-" : ""}${wholeText}${fractionDisplay}`; +} + // ── Duration / time formatters ──────────────────────────────────────────────── /**