- {fmt(c.amount)} {c.asset}{' '}
+ {fmtUnits(BigInt(c.amount))} {c.asset}{' '}
{st}
{c.demo && demo}
@@ -462,12 +465,12 @@ export default function DashboardPage() {
{st === 'active'
? `${pct}% elapsed, ${left} day${left === 1 ? '' : 's'} to maturity`
: st === 'matured'
- ? `Matured. Projected payout ${fmt(c.amount + projectedYield(c))} ${c.asset}`
+ ? `Matured. Projected payout ${formatAmount(BigInt(c.amount) + projectedYield(c))} ${c.asset}`
: st === 'settled'
- ? `Settled with ${fmt(projectedYield(c))} ${c.asset} yield`
+ ? `Settled with ${formatAmount(projectedYield(c))} ${c.asset} yield`
: st === 'disputed' || st === 'violated'
? `Frozen. Only the operator can release or refund the funds after resolving the dispute.`
- : `Exited early, ${fmt(exitBreakdown(c).penaltyAmount)} ${c.asset} penalty paid`}
+ : `Exited early, ${formatAmount(exitBreakdown(c).penaltyAmount)} ${c.asset} penalty paid`}
);
@@ -495,17 +498,17 @@ export default function DashboardPage() {
- Principal
-
- {fmt(breakdown.principal)} {target.asset}
+ {formatAmount(breakdown.principal)} {target.asset}
- Penalty
- {breakdown.penaltyPct}%
- Penalty amount
-
- {fmt(breakdown.penaltyAmount)} {target.asset}
+ {formatAmount(breakdown.penaltyAmount)} {target.asset}
- Amount returned
-
- {fmt(breakdown.returned)} {target.asset}
+ {formatAmount(breakdown.returned)} {target.asset}
)}
diff --git a/src/components/__tests__/DisputeDialog.test.tsx b/src/components/__tests__/DisputeDialog.test.tsx
index 183a1bddd..13ec4b4cb 100644
--- a/src/components/__tests__/DisputeDialog.test.tsx
+++ b/src/components/__tests__/DisputeDialog.test.tsx
@@ -18,7 +18,7 @@ const commitment: Commitment = {
id: 'CMT-1',
type: 'balanced',
asset: 'USDC',
- amount: 1000,
+ amount: '10000000000',
durationDays: 30,
maxLoss: 8,
penalty: 5,
diff --git a/src/lib/__tests__/amount.test.ts b/src/lib/__tests__/amount.test.ts
new file mode 100644
index 000000000..64aec986c
--- /dev/null
+++ b/src/lib/__tests__/amount.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it } from 'vitest';
+import {
+ AMOUNT_ERROR_MESSAGES,
+ MAX_AMOUNT,
+ MAX_DAYS,
+ STELLAR_DECIMALS,
+ formatAmount,
+ parseAmount,
+ parseDays,
+ penaltyOf,
+ percentToBps,
+ yieldOf,
+} from '../amount';
+
+const reasonOf = (input: string, decimals?: number) => {
+ const r = parseAmount(input, decimals);
+ return r.ok ? null : r.reason;
+};
+
+describe('parseAmount', () => {
+ it.each([
+ ['1e3', 'invalid_characters'],
+ ['1E3', 'invalid_characters'],
+ ['0x10', 'invalid_characters'],
+ ['.5.', 'invalid_characters'],
+ ['.5', 'invalid_characters'],
+ ['5.', 'invalid_characters'],
+ ['-1', 'invalid_characters'],
+ ['+1', 'invalid_characters'],
+ ['1,000', 'invalid_characters'],
+ ['1 000', 'invalid_characters'],
+ ['abc', 'invalid_characters'],
+ ['Infinity', 'invalid_characters'],
+ ['12', 'invalid_characters'],
+ ['5\n5', 'invalid_characters'],
+ ['', 'empty'],
+ [' ', 'empty'],
+ ['\t\n', 'empty'],
+ ['0', 'zero'],
+ ['0.0', 'zero'],
+ ['0.0000000', 'zero'],
+ ])('rejects %j as %s', (input, reason) => {
+ expect(reasonOf(input)).toBe(reason);
+ });
+
+ it('gives every rejection reason a distinct user-facing message', () => {
+ const messages = Object.values(AMOUNT_ERROR_MESSAGES);
+ expect(new Set(messages).size).toBe(messages.length);
+ expect(AMOUNT_ERROR_MESSAGES.zero).toMatch(/greater than zero/);
+ });
+
+ it('trims surrounding whitespace but not whitespace inside the number', () => {
+ expect(parseAmount(' 5 ')).toEqual({ ok: true, value: 50_000_000n });
+ expect(parseAmount('\t5.5\n')).toEqual({ ok: true, value: 55_000_000n });
+ expect(reasonOf('5 5')).toBe('invalid_characters');
+ });
+
+ it('accepts plain integers and decimals, including leading zeros', () => {
+ expect(parseAmount('5')).toEqual({ ok: true, value: 50_000_000n });
+ expect(parseAmount('12.5')).toEqual({ ok: true, value: 125_000_000n });
+ expect(parseAmount('007')).toEqual({ ok: true, value: 70_000_000n });
+ expect(parseAmount('0.5')).toEqual({ ok: true, value: 5_000_000n });
+ });
+
+ it('accepts 1 stroop and rejects an 8th decimal', () => {
+ expect(parseAmount('0.0000001')).toEqual({ ok: true, value: 1n });
+ expect(reasonOf('0.00000001')).toBe('too_many_decimals');
+ expect(reasonOf('1.00000000')).toBe('too_many_decimals');
+ });
+
+ it('honours a custom decimals parameter', () => {
+ expect(parseAmount('1.5', 2)).toEqual({ ok: true, value: 150n });
+ expect(reasonOf('1.555', 2)).toBe('too_many_decimals');
+ expect(parseAmount('42', 0)).toEqual({ ok: true, value: 42n });
+ expect(reasonOf('42.0', 0)).toBe('too_many_decimals');
+ expect(() => parseAmount('1', -1)).toThrow(RangeError);
+ expect(() => parseAmount('1', 1.5)).toThrow(RangeError);
+ });
+
+ it('accepts exactly the contract maximum and rejects one stroop above', () => {
+ expect(MAX_AMOUNT).toBe(10n ** 12n);
+ expect(parseAmount('100000')).toEqual({ ok: true, value: MAX_AMOUNT });
+ expect(parseAmount('100000.0000000')).toEqual({ ok: true, value: MAX_AMOUNT });
+ expect(reasonOf('100000.0000001')).toBe('above_max');
+ expect(reasonOf('100001')).toBe('above_max');
+ expect(reasonOf('1000000000000000000000')).toBe('above_max');
+ expect(reasonOf('9'.repeat(500))).toBe('above_max');
+ });
+
+ it('never loses precision that a float would', () => {
+ expect(parseAmount('0.1234567')).toEqual({ ok: true, value: 1_234_567n });
+ expect(parseAmount('99999.9999999')).toEqual({ ok: true, value: 999_999_999_999n });
+ });
+});
+
+describe('parseDays', () => {
+ it.each([
+ ['1e1', 'invalid_characters'],
+ ['14.0', 'invalid_characters'],
+ ['-3', 'invalid_characters'],
+ ['+7', 'invalid_characters'],
+ ['0x10', 'invalid_characters'],
+ ['1 4', 'invalid_characters'],
+ ['', 'empty'],
+ [' ', 'empty'],
+ ['0', 'below_min'],
+ ['366', 'above_max'],
+ ['99999999999999999999999', 'above_max'],
+ ])('rejects %j as %s', (input, reason) => {
+ const r = parseDays(input);
+ expect(r.ok ? null : r.reason).toBe(reason);
+ });
+
+ it('trims surrounding whitespace like parseAmount', () => {
+ expect(parseDays(' 7 ')).toEqual({ ok: true, value: 7 });
+ });
+
+ it('checks the inclusive range, defaulting to 1..365', () => {
+ expect(parseDays('1')).toEqual({ ok: true, value: 1 });
+ expect(parseDays(String(MAX_DAYS))).toEqual({ ok: true, value: 365 });
+ expect(parseDays('13', { min: 14 })).toEqual({ ok: false, reason: 'below_min' });
+ expect(parseDays('14', { min: 14 })).toEqual({ ok: true, value: 14 });
+ expect(parseDays('31', { max: 30 })).toEqual({ ok: false, reason: 'above_max' });
+ });
+});
+
+describe('formatAmount', () => {
+ it('round trips parse then format at full precision', () => {
+ const cases: [string, string][] = [
+ ['1', '1'],
+ ['1000', '1,000'],
+ ['12.50', '12.5'],
+ ['0.0000001', '0.0000001'],
+ ['99999.9999999', '99,999.9999999'],
+ ['100000', '100,000'],
+ ['0.1234567', '0.1234567'],
+ ];
+ for (const [input, shown] of cases) {
+ const parsed = parseAmount(input);
+ expect(parsed.ok && formatAmount(parsed.value)).toBe(shown);
+ expect(parseAmount(shown.replace(/,/g, ''))).toEqual(parsed);
+ }
+ });
+
+ it('shows 1 stroop as 0.0000001 at full precision', () => {
+ expect(formatAmount(1n)).toBe('0.0000001');
+ expect(formatAmount(10_000_001n)).toBe('1.0000001');
+ });
+
+ it('groups thousands and trims trailing zeros', () => {
+ expect(formatAmount(10_000_000_000n)).toBe('1,000');
+ expect(formatAmount(12_345_678_900_000n)).toBe('1,234,567.89');
+ expect(formatAmount(5_000_000n)).toBe('0.5');
+ expect(formatAmount(0n)).toBe('0');
+ });
+
+ it('truncates to maxFraction instead of rounding', () => {
+ expect(formatAmount(19_999_999n, 7, { maxFraction: 2 })).toBe('1.99');
+ expect(formatAmount(19_999_999n)).toBe('1.9999999');
+ expect(formatAmount(10_000_000n, 7, { maxFraction: 2 })).toBe('1');
+ expect(formatAmount(15_000_000n, 7, { maxFraction: 0 })).toBe('1');
+ });
+
+ it('does not show a non-zero amount as 0 when truncated', () => {
+ expect(formatAmount(1n, 7, { maxFraction: 2 })).toBe('<0.01');
+ expect(formatAmount(99_999n, 7, { maxFraction: 2 })).toBe('<0.01');
+ expect(formatAmount(1n, 7, { maxFraction: 0 })).toBe('<1');
+ expect(formatAmount(0n, 7, { maxFraction: 2 })).toBe('0');
+ });
+
+ it('formats with other decimals and negative values', () => {
+ expect(formatAmount(150n, 2)).toBe('1.5');
+ expect(formatAmount(42n, 0)).toBe('42');
+ expect(formatAmount(-5_000_000n)).toBe('-0.5');
+ });
+});
+
+describe('basis point math', () => {
+ it('converts percentages to basis points exactly', () => {
+ expect(percentToBps(4.5)).toBe(450n);
+ expect(percentToBps(9)).toBe(900n);
+ expect(percentToBps(16)).toBe(1_600n);
+ expect(percentToBps(0.07)).toBe(7n);
+ });
+
+ it('computes the penalty as amount * bps / 10_000 (hand calculated)', () => {
+ // 1,000 USDC at 5%: 10_000_000_000 * 500 / 10_000 = 500_000_000 (50 USDC)
+ expect(penaltyOf(10_000_000_000n, 5)).toBe(500_000_000n);
+ // 2,500 USDC at 3% = 75 USDC
+ expect(penaltyOf(25_000_000_000n, 3)).toBe(750_000_000n);
+ // 100,000 at 8% = 8,000
+ expect(penaltyOf(MAX_AMOUNT, 8)).toBe(80_000_000_000n);
+ });
+
+ it('rounds the penalty down to a whole stroop, like the contract', () => {
+ // 33 stroops at 5%: 33 * 500 / 10_000 = 1.65, floored to 1; refund 32
+ expect(penaltyOf(33n, 5)).toBe(1n);
+ expect(33n - penaltyOf(33n, 5)).toBe(32n);
+ // 1 stroop at 5% is 0.05, floored to 0
+ expect(penaltyOf(1n, 5)).toBe(0n);
+ });
+
+ it('computes yield as amount * apyBps * days / (365 * 10_000) (hand calculated)', () => {
+ // 365 days at 16% on 100: 1_000_000_000 * 1600 * 365 / (365 * 10_000) = 160_000_000 exactly
+ expect(yieldOf(1_000_000_000n, 16, 365)).toBe(160_000_000n);
+ // 1,000 at 9% for 14 days: 10_000_000_000 * 900 * 14 / 3_650_000 = 34_520_547.945..., floored
+ expect(yieldOf(10_000_000_000n, 9, 14)).toBe(34_520_547n);
+ // 2,500 at 4.5% for 7 days: 25_000_000_000 * 450 * 7 / 3_650_000 = 21_575_342.46..., floored
+ expect(yieldOf(25_000_000_000n, 4.5, 7)).toBe(21_575_342n);
+ });
+
+ it('floors once at the end, never promising more than base units can pay', () => {
+ const amount = 12_345_678n;
+ const exact = Number(amount) * 0.09 * 14 / 365;
+ const got = yieldOf(amount, 9, 14);
+ expect(Number(got)).toBeLessThanOrEqual(exact);
+ expect(exact - Number(got)).toBeLessThan(1);
+ });
+
+ it('handles the maximum amount', () => {
+ // 1e12 * 1600 * 365 / (365 * 1e4) = 1.6e11 exactly
+ expect(yieldOf(MAX_AMOUNT, 16, 365)).toBe(160_000_000_000n);
+ expect(STELLAR_DECIMALS).toBe(7);
+ });
+});
diff --git a/src/lib/__tests__/commitments.test.ts b/src/lib/__tests__/commitments.test.ts
index ddfb195b4..e647d6e9a 100644
--- a/src/lib/__tests__/commitments.test.ts
+++ b/src/lib/__tests__/commitments.test.ts
@@ -1,11 +1,145 @@
-import { describe, it, expect } from 'vitest';
-import { validateCommitment, type Commitment } from '../commitments';
+import { beforeEach, describe, expect, it } from 'vitest';
+import {
+ loadCommitments,
+ normalizeStoredAmount,
+ projectedYield,
+ resetCommitments,
+ sampleCommitments,
+ saveCommitments,
+ validateCommitment,
+ type Commitment,
+} from '../commitments';
+
+const KEY = 'commitlabs.commitments.v1';
+
+const record = (overrides: Record
= {}) => ({
+ id: 'CMT-1',
+ type: 'balanced',
+ asset: 'USDC',
+ amount: 2500,
+ durationDays: 30,
+ maxLoss: 8,
+ penalty: 5,
+ apy: 9,
+ createdAt: 1_700_000_000_000,
+ status: 'active',
+ owner: null,
+ txHash: null,
+ ...overrides,
+});
+
+const store = (payload: unknown) => window.localStorage.setItem(KEY, JSON.stringify(payload));
+const stored = () => JSON.parse(window.localStorage.getItem(KEY) ?? 'null');
+
+beforeEach(() => {
+ resetCommitments();
+ window.localStorage.clear();
+});
+
+describe('normalizeStoredAmount', () => {
+ it.each([
+ [2500, '25000000000'],
+ [0.5, '5000000'],
+ [0, '0'],
+ [0.0000001, '1'],
+ [1000.25, '10002500000'],
+ ])('converts legacy number %s to %s base units', (legacy, expected) => {
+ expect(normalizeStoredAmount(legacy)).toBe(expected);
+ });
+
+ it('removes binary float noise from legacy values', () => {
+ expect(normalizeStoredAmount(0.1 + 0.2)).toBe('3000000');
+ expect(normalizeStoredAmount(1.005 * 100)).toBe('1005000000');
+ });
+
+ it('passes digit strings through and strips leading zeros', () => {
+ expect(normalizeStoredAmount('25000000000')).toBe('25000000000');
+ expect(normalizeStoredAmount('0007')).toBe('7');
+ expect(normalizeStoredAmount('0000')).toBe('0');
+ });
+
+ it.each([-1, NaN, Infinity, 1e21, '', '1.5', '-5', '1e3', ' 5', 'abc', null, undefined, {}, [], true])(
+ 'rejects malformed value %j',
+ (bad) => {
+ expect(normalizeStoredAmount(bad)).toBeNull();
+ },
+ );
+});
+
+describe('validateCommitment amounts', () => {
+ it('converts a legacy numeric record', () => {
+ expect(validateCommitment(record())?.amount).toBe('25000000000');
+ });
+
+ it('keeps a current string record unchanged', () => {
+ expect(validateCommitment(record({ amount: '25000000000' }))?.amount).toBe('25000000000');
+ });
+
+ it('rejects malformed amounts without throwing', () => {
+ for (const amount of [-5, '1e3', 'abc', null, NaN, undefined]) {
+ expect(validateCommitment(record({ amount }))).toBeNull();
+ }
+ });
+});
+
+describe('loadCommitments with legacy storage', () => {
+ it('shows a legacy v1 array of numeric records exactly and rewrites it as v3 strings', () => {
+ store([record({ amount: 1234.5 })]);
+ const result = loadCommitments();
+ expect(result.dropped).toBe(0);
+ expect(result.items.map((c) => c.amount)).toEqual(['12345000000']);
+ expect(stored()).toMatchObject({ version: 3, items: [{ amount: '12345000000' }] });
+ });
+
+ it('upgrades a v2 envelope with numeric amounts in place', () => {
+ store({ version: 2, items: [record({ amount: 10000 }), record({ id: 'CMT-2', amount: 0.25 })] });
+ const result = loadCommitments();
+ expect(result.items.map((c) => c.amount)).toEqual(['100000000000', '2500000']);
+ expect(stored().version).toBe(3);
+ expect(stored().items.every((c: { amount: unknown }) => typeof c.amount === 'string')).toBe(true);
+ });
+
+ it('drops a malformed stored amount and keeps the rest, reporting the drop', () => {
+ store({
+ version: 2,
+ items: [record({ amount: 'oops' }), record({ id: 'CMT-2', amount: -3 }), record({ id: 'CMT-3' })],
+ });
+ const result = loadCommitments();
+ expect(result.corrupt).toBe(false);
+ expect(result.dropped).toBe(2);
+ expect(result.items.map((c) => c.id)).toEqual(['CMT-3']);
+ });
+
+ it('round trips new string records without rewriting them', () => {
+ const [sample] = sampleCommitments();
+ expect(saveCommitments([sample])).toBe(true);
+ const before = window.localStorage.getItem(KEY);
+ expect(loadCommitments().items).toEqual([sample]);
+ expect(window.localStorage.getItem(KEY)).toBe(before);
+ });
+});
+
+describe('projectedYield', () => {
+ it('uses the contract integer yield math on the stored base units', () => {
+ const c = validateCommitment(record({ amount: '10000000000', durationDays: 14 }))!;
+ // 1,000 balanced (700 bps) for 14 days = 2.6849315... floored to 26_849_315 stroops
+ expect(projectedYield(c)).toBe(26_849_315n);
+ });
+});
+
+describe('sampleCommitments', () => {
+ it('uses base-unit strings that validate', () => {
+ const samples = sampleCommitments();
+ expect(samples.map((c) => c.amount)).toEqual(['25000000000', '100000000000']);
+ for (const s of samples) expect(validateCommitment(s)).not.toBeNull();
+ });
+});
const base: Commitment = {
id: 'CMT-1',
type: 'balanced',
asset: 'USDC',
- amount: 1000,
+ amount: '10000000000',
durationDays: 30,
maxLoss: 8,
penalty: 5,
diff --git a/src/lib/amount.ts b/src/lib/amount.ts
new file mode 100644
index 000000000..a48d497e4
--- /dev/null
+++ b/src/lib/amount.ts
@@ -0,0 +1,135 @@
+/**
+ * Exact fixed-point amounts. User input is parsed into bigint base units (7
+ * decimals for Stellar assets) so nothing on the money path touches a float.
+ */
+
+/** Stellar assets, including XLM, carry 7 decimals (1 unit = 10^7 stroops). */
+export const STELLAR_DECIMALS = 7;
+
+/** Mirrors `MAX_AMOUNT` in contracts/escrow/src/lib.rs: 1e12 stroops = 100,000 XLM. */
+export const MAX_AMOUNT = 1_000_000_000_000n;
+
+/** Longest lock the wizard accepts, in days. */
+export const MAX_DAYS = 365;
+
+const BPS_DENOMINATOR = 10_000n;
+const DAYS_PER_YEAR = 365n;
+
+export type AmountError =
+ | 'empty'
+ | 'invalid_characters'
+ | 'too_many_decimals'
+ | 'zero'
+ | 'above_max';
+
+export type AmountResult = { ok: true; value: bigint } | { ok: false; reason: AmountError };
+
+export const AMOUNT_ERROR_MESSAGES: Record = {
+ empty: 'Enter an amount.',
+ invalid_characters: 'Use digits and one decimal point only, like 12.5. No signs, commas or exponents.',
+ too_many_decimals: 'Too many decimal places. Stellar assets support up to 7.',
+ zero: 'Enter an amount greater than zero.',
+ above_max: 'That is above the maximum of 100,000 per commitment.',
+};
+
+const AMOUNT_PATTERN = /^\d+(\.\d+)?$/;
+
+/**
+ * Parses a decimal string into base units.
+ *
+ * Whitespace policy: leading and trailing whitespace is trimmed, so " 5 " is
+ * accepted as 5; whitespace inside the number ("1 000") is rejected. Only
+ * `^\d+(\.\d+)?$` is accepted after trimming, so ".5", "5.", "1e3", "0x10",
+ * "-1" and "1,000" are all `invalid_characters`. Leading zeros are allowed.
+ */
+export function parseAmount(input: string, decimals = STELLAR_DECIMALS): AmountResult {
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 38) {
+ throw new RangeError(`decimals must be an integer from 0 to 38, got ${decimals}`);
+ }
+
+ const text = input.trim();
+ if (text === '') return { ok: false, reason: 'empty' };
+ if (!AMOUNT_PATTERN.test(text)) return { ok: false, reason: 'invalid_characters' };
+
+ const [whole, fraction = ''] = text.split('.');
+ if (fraction.length > decimals) return { ok: false, reason: 'too_many_decimals' };
+
+ const value = BigInt(whole + fraction.padEnd(decimals, '0'));
+ if (value === 0n) return { ok: false, reason: 'zero' };
+ if (value > MAX_AMOUNT) return { ok: false, reason: 'above_max' };
+ return { ok: true, value };
+}
+
+export interface FormatOptions {
+ /**
+ * Cap on fraction digits. Extra digits are truncated, never rounded, so a
+ * summary never shows more than the real amount. A non-zero value that
+ * truncates to zero shows as "<0.01" rather than "0". Omit for full precision.
+ */
+ maxFraction?: number;
+}
+
+/** Formats base units as a grouped decimal string, trimming trailing zeros. */
+export function formatAmount(
+ value: bigint,
+ decimals = STELLAR_DECIMALS,
+ { maxFraction }: FormatOptions = {},
+): string {
+ const negative = value < 0n;
+ const digits = (negative ? -value : value).toString().padStart(decimals + 1, '0');
+ const whole = digits.slice(0, digits.length - decimals);
+ let fraction = digits.slice(digits.length - decimals);
+
+ if (maxFraction !== undefined) fraction = fraction.slice(0, Math.max(0, maxFraction));
+ fraction = fraction.replace(/0+$/, '');
+
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+ const sign = negative ? '-' : '';
+
+ if (maxFraction !== undefined && value !== 0n && grouped === '0' && fraction === '') {
+ const smallest = maxFraction > 0 ? `0.${'0'.repeat(maxFraction - 1)}1` : '1';
+ return `<${smallest}`;
+ }
+ return `${sign}${grouped}${fraction ? `.${fraction}` : ''}`;
+}
+
+export type DaysError = 'empty' | 'invalid_characters' | 'below_min' | 'above_max';
+
+export type DaysResult = { ok: true; value: number } | { ok: false; reason: DaysError };
+
+/**
+ * Parses a whole number of days. Same trim policy as `parseAmount`; digits
+ * only, so "1e1", "14.0", "-3" and "+7" are rejected. "0" is `below_min`.
+ */
+export function parseDays(input: string, { min = 1, max = MAX_DAYS } = {}): DaysResult {
+ const text = input.trim();
+ if (text === '') return { ok: false, reason: 'empty' };
+ if (!/^\d+$/.test(text)) return { ok: false, reason: 'invalid_characters' };
+
+ const value = Number(text);
+ if (value < min) return { ok: false, reason: 'below_min' };
+ if (value > max) return { ok: false, reason: 'above_max' };
+ return { ok: true, value };
+}
+
+/** Converts a percentage with at most 2 decimals (4.5) to basis points (450). */
+export function percentToBps(percent: number): bigint {
+ return BigInt(Math.round(percent * 100));
+}
+
+/**
+ * Early exit penalty: `amount * bps / 10_000`, rounded down like the contract's
+ * `compute_refund_amount`. The refund is `amount - penalty`, so no stroop is lost.
+ */
+export function penaltyOf(amount: bigint, penaltyPercent: number): bigint {
+ return (amount * percentToBps(penaltyPercent)) / BPS_DENOMINATOR;
+}
+
+/**
+ * Simple-interest yield: `amount * apyBps * days / (365 * 10_000)`, rounded down
+ * once at the end (the contract's `calculate_accrued_yield` floors the same way),
+ * so the projection never promises more than the base-unit math can pay.
+ */
+export function yieldOf(amount: bigint, apyPercent: number, days: number): bigint {
+ return (amount * percentToBps(apyPercent) * BigInt(days)) / (DAYS_PER_YEAR * BPS_DENOMINATOR);
+}
diff --git a/src/lib/commitments.ts b/src/lib/commitments.ts
index efa2b9241..e76a73adb 100644
--- a/src/lib/commitments.ts
+++ b/src/lib/commitments.ts
@@ -1,7 +1,8 @@
+import { STELLAR_DECIMALS, formatAmount } from './amount';
import { isStorageAvailable, readRaw, removeRaw, subscribeStorage, writeRaw } from './storage';
import type { DisputeRecord } from '@/lib/soroban/dispute';
-import { DISPLAY_APY, type RiskProfile, parseToStroops, projectYieldStroops } from './yield';
+import { DISPLAY_APY, type RiskProfile, projectYieldStroops } from './yield';
import { FALLBACK_PROTOCOL_PARAMS } from './protocol';
export type CommitmentType = RiskProfile;
@@ -51,7 +52,8 @@ export interface Commitment {
id: string;
type: CommitmentType;
asset: string;
- amount: number;
+ /** Principal in base units (7 decimals), as a decimal string of digits. */
+ amount: string;
durationDays: number;
maxLoss: number;
penalty: number;
@@ -79,7 +81,7 @@ export interface LoadResult {
const KEY = 'commitlabs.commitments.v1';
const DAY = 86_400_000;
-const CURRENT_VERSION = 2;
+const CURRENT_VERSION = 3;
/** Sane upper bounds so hostile or accidentally huge strings are rejected. */
const MAX_ID = 128;
@@ -108,13 +110,12 @@ export function progressOf(c: Commitment, now = Date.now()): number {
return Math.max(0, Math.min(1, (now - c.createdAt) / total));
}
-export function projectedYield(c: Commitment): number {
- const stroops = parseToStroops(String(c.amount));
- if (stroops !== null) {
- const yieldStroops = projectYieldStroops(stroops, c.durationDays, c.type);
- return Number(yieldStroops) / 10_000_000;
- }
- return (c.amount * (c.apy / 100) * c.durationDays) / 365;
+/**
+ * Projected yield in base units, using the contract's integer math
+ * (`projectYieldStroops`) on the exact stored principal.
+ */
+export function projectedYield(c: Commitment): bigint {
+ return projectYieldStroops(BigInt(c.amount), c.durationDays, c.type);
}
let fallbackCounter = 0;
@@ -145,6 +146,21 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
+/**
+ * Converts a stored amount to base-unit digits. Records written before exact
+ * amounts hold a float in whole units (`2500.5`); those go through
+ * `toFixed(STELLAR_DECIMALS)` so binary noise like 0.1 + 0.2 lands on 0.3, then
+ * scale to base units. Current records already hold digit strings. Anything
+ * else (negative, NaN, exponent form from huge floats, junk) returns null.
+ */
+export function normalizeStoredAmount(value: unknown): string | null {
+ if (typeof value === 'string') return /^\d+$/.test(value) ? value.replace(/^0+(?=\d)/, '') : null;
+ if (!isFiniteNumber(value) || value < 0) return null;
+ const fixed = value.toFixed(STELLAR_DECIMALS);
+ if (!/^\d+\.\d+$/.test(fixed)) return null;
+ return fixed.replace('.', '').replace(/^0+(?=\d)/, '');
+}
+
function isBoundedString(value: unknown, max: number): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= max;
}
@@ -161,7 +177,8 @@ export function validateCommitment(value: unknown): Commitment | null {
if (!isBoundedString(o.id, MAX_ID)) return null;
if (typeof o.type !== 'string' || !TYPE_IDS.has(o.type)) return null;
if (!isBoundedString(o.asset, MAX_ASSET)) return null;
- if (!isFiniteNumber(o.amount) || o.amount < 0) return null;
+ const amount = normalizeStoredAmount(o.amount);
+ if (amount === null) return null;
if (!isFiniteNumber(o.durationDays) || o.durationDays <= 0) return null;
if (!isFiniteNumber(o.maxLoss) || o.maxLoss < 0) return null;
if (!isFiniteNumber(o.penalty) || o.penalty < 0) return null;
@@ -177,7 +194,7 @@ export function validateCommitment(value: unknown): Commitment | null {
id: o.id,
type: o.type as CommitmentType,
asset: o.asset,
- amount: o.amount,
+ amount,
durationDays: o.durationDays,
maxLoss: o.maxLoss,
penalty: o.penalty,
@@ -197,9 +214,16 @@ export function validateCommitment(value: unknown): Commitment | null {
* future schema version, append a step; never remove or reorder existing ones.
*
* Step 1: v1 (bare `Commitment[]`) -> v2 (`{ version: 2, items: [...] }`).
+ * Step 2: v2 -> v3, amounts become base-unit digit strings. The conversion itself
+ * is `normalizeStoredAmount` inside `validateCommitment`; this step only bumps the
+ * version so `loadCommitments` rewrites legacy numeric amounts in place.
*/
const MIGRATIONS: ReadonlyArray<(value: unknown) => unknown> = [
(value) => (Array.isArray(value) ? { version: 2, items: value } : value),
+ (value) =>
+ value && typeof value === 'object' && (value as { version?: unknown }).version === 2
+ ? { ...(value as object), version: 3 }
+ : value,
];
/** Runs the migration pipeline over a raw parsed value. */
@@ -308,7 +332,7 @@ export function sampleCommitments(): Commitment[] {
id: newId(),
type: 'balanced',
asset: 'USDC',
- amount: 2500,
+ amount: '25000000000',
durationDays: 30,
maxLoss: 8,
penalty: FALLBACK_PROTOCOL_PARAMS.penalties.balanced / 100,
@@ -323,7 +347,7 @@ export function sampleCommitments(): Commitment[] {
id: newId(),
type: 'safe',
asset: 'XLM',
- amount: 10000,
+ amount: '100000000000',
durationDays: 14,
maxLoss: 2,
penalty: FALLBACK_PROTOCOL_PARAMS.penalties.safe / 100,
@@ -340,3 +364,8 @@ export function sampleCommitments(): Commitment[] {
export function fmt(n: number, digits = 2): string {
return n.toLocaleString('en-US', { maximumFractionDigits: digits });
}
+
+/** Abbreviated base-unit display for summaries; use `formatAmount` for full precision. */
+export function fmtUnits(value: bigint, maxFraction = 2): string {
+ return formatAmount(value, STELLAR_DECIMALS, { maxFraction });
+}