From acd887e6d464518c78486325db691c21eed50333 Mon Sep 17 00:00:00 2001 From: Abioladory123 Date: Fri, 23 Oct 2026 16:38:01 +0100 Subject: [PATCH] Parse commitment amounts as exact fixed-point values Add parseAmount/parseDays that reject malformed input with specific reasons, keep amounts as bigint base units (7 decimals, max 1e12 like the contract), compute penalty and yield with basis point math, and convert legacy numeric records on load. The wizard and dashboard use them. --- src/app/create/create.amount.test.tsx | 121 ++++++++++++ src/app/create/page.tsx | 64 ++++-- src/app/dashboard/dashboard.a11y.test.tsx | 4 +- src/app/dashboard/page.tsx | 29 +-- src/lib/__tests__/amount.test.ts | 225 ++++++++++++++++++++++ src/lib/__tests__/commitments.test.ts | 135 +++++++++++++ src/lib/amount.ts | 135 +++++++++++++ src/lib/commitments.ts | 46 ++++- 8 files changed, 716 insertions(+), 43 deletions(-) create mode 100644 src/app/create/create.amount.test.tsx create mode 100644 src/lib/__tests__/amount.test.ts create mode 100644 src/lib/__tests__/commitments.test.ts create mode 100644 src/lib/amount.ts diff --git a/src/app/create/create.amount.test.tsx b/src/app/create/create.amount.test.tsx new file mode 100644 index 000000000..480ac960c --- /dev/null +++ b/src/app/create/create.amount.test.tsx @@ -0,0 +1,121 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadCommitments, resetCommitments } from '@/lib/commitments'; +import CreatePage from './page'; + +vi.mock('@/components/WalletProvider', () => ({ + useWallet: () => ({ address: null }), +})); + +const click = (name: string) => fireEvent.click(screen.getByRole('button', { name })); + +function openConfigure() { + render(); + click('Continue'); + return { + amount: screen.getByLabelText('Amount') as HTMLInputElement, + days: screen.getByLabelText('Lock duration (days)') as HTMLInputElement, + }; +} + +beforeEach(() => { + resetCommitments(); + window.localStorage.clear(); +}); +afterEach(cleanup); + +describe('create wizard amount input', () => { + it('constrains the inputs with a pattern and keyboard hint', () => { + const { amount, days } = openConfigure(); + expect(amount.getAttribute('inputmode')).toBe('decimal'); + expect(amount.getAttribute('pattern')).toBe('\\d+(\\.\\d+)?'); + expect(days.getAttribute('inputmode')).toBe('numeric'); + expect(days.getAttribute('pattern')).toBe('\\d+'); + }); + + it.each([ + ['1e3', /digits and one decimal point/i], + ['0x10', /digits and one decimal point/i], + ['.5.', /digits and one decimal point/i], + ['-1', /digits and one decimal point/i], + ['1,000', /digits and one decimal point/i], + ['', /enter an amount\./i], + [' ', /enter an amount\./i], + ['0', /greater than zero/i], + ['0.00000001', /too many decimal places/i], + ['100000.0000001', /above the maximum/i], + ['1000000000000000000000', /above the maximum/i], + ])('shows the inline reason for %j and blocks Review', (value, message) => { + const { amount } = openConfigure(); + fireEvent.change(amount, { target: { value } }); + fireEvent.blur(amount); + + const err = document.getElementById('amount-err'); + expect(err?.textContent).toMatch(message); + expect(amount.getAttribute('aria-invalid')).toBe('true'); + + click('Review'); + expect(screen.getByRole('heading', { name: 'Configure' })).toBeTruthy(); + expect(screen.queryByRole('heading', { name: 'Review' })).toBeNull(); + expect(document.activeElement).toBe(amount); + }); + + it('shows the reason on a Review attempt even without blur', () => { + const { amount } = openConfigure(); + fireEvent.change(amount, { target: { value: '1e3' } }); + expect(document.getElementById('amount-err')).toBeNull(); + click('Review'); + expect(document.getElementById('amount-err')?.textContent).toMatch(/digits and one decimal point/i); + }); + + it.each([ + ['1e1', /whole days only/i], + ['14.0', /whole days only/i], + ['-3', /whole days only/i], + ['', /enter the lock duration/i], + ['13', /minimum lock for balanced is 14 days/i], + ['366', /maximum lock is 365 days/i], + ])('rejects duration %j with a specific reason', (value, message) => { + const { days } = openConfigure(); + fireEvent.change(days, { target: { value } }); + fireEvent.blur(days); + expect(document.getElementById('days-err')?.textContent).toMatch(message); + click('Review'); + expect(screen.getByRole('heading', { name: 'Configure' })).toBeTruthy(); + }); + + it('trims whitespace around a valid amount and reviews it with exact BigInt math', () => { + const { amount, days } = openConfigure(); + fireEvent.change(amount, { target: { value: ' 1000 ' } }); + fireEvent.change(days, { target: { value: ' 14 ' } }); + expect(document.getElementById('amount-err')).toBeNull(); + click('Review'); + + expect(screen.getByRole('heading', { name: 'Review' })).toBeTruthy(); + expect(screen.getByText('1,000 USDC')).toBeTruthy(); + // 1,000 at 9% for 14 days = 3.4520547 (floored), penalty 5% = 50 + expect(screen.getByText('3.4520547 USDC')).toBeTruthy(); + expect(screen.getByText('5% (50 USDC)')).toBeTruthy(); + }); + + it('accepts 1 stroop, shows it at full precision and stores base units as a string', () => { + const { amount } = openConfigure(); + fireEvent.change(amount, { target: { value: '0.0000001' } }); + click('Review'); + expect(screen.getByText('0.0000001 USDC')).toBeTruthy(); + + fireEvent.click(screen.getByRole('checkbox')); + click('Create commitment'); + const [saved] = loadCommitments().items; + expect(saved.amount).toBe('1'); + }); + + it('stores the exact contract maximum', () => { + const { amount } = openConfigure(); + fireEvent.change(amount, { target: { value: '100000' } }); + click('Review'); + fireEvent.click(screen.getByRole('checkbox')); + click('Create commitment'); + expect(loadCommitments().items[0].amount).toBe('1000000000000'); + }); +}); diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 5753f5a36..053d19915 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -2,9 +2,19 @@ import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; +import { + AMOUNT_ERROR_MESSAGES, + MAX_DAYS, + formatAmount, + parseAmount, + parseDays, + penaltyOf, + yieldOf, + type DaysError, +} from '@/lib/amount'; import { TYPES, - fmt, + fmtUnits, loadCommitments, newId, saveCommitments, @@ -18,6 +28,19 @@ import { ESCROW_CONTRACT_ID, explorerContractUrl, config } from '@/lib/config'; const ASSETS = ['USDC', 'XLM', 'EURC']; const STEP_LABELS = ['Type', 'Configure', 'Review', 'Done']; +function daysMessage(reason: DaysError, name: string, minDays: number): string { + switch (reason) { + case 'empty': + return 'Enter the lock duration in days.'; + case 'invalid_characters': + return 'Use whole days only, digits 0 to 9.'; + case 'below_min': + return `Minimum lock for ${name} is ${minDays} days.`; + case 'above_max': + return `Maximum lock is ${MAX_DAYS} days.`; + } +} + export default function CreatePage() { const { address } = useWallet(); const [step, setStep] = useState(0); @@ -55,22 +78,21 @@ export default function CreatePage() { }, []); const spec = specFor(type); - const amt = Number(amount); - const dur = Number(days); - const amountErr = !Number.isFinite(amt) || amt <= 0 ? 'Enter an amount greater than zero.' : null; - const daysErr = - !Number.isInteger(dur) || dur < spec.minDays - ? `Minimum lock for ${spec.name} is ${spec.minDays} days.` - : dur > 365 - ? 'Maximum lock is 365 days.' - : null; - const valid = !amountErr && !daysErr; - const projected = valid ? (amt * (spec.apy / 100) * dur) / 365 : 0; + const parsedAmount = parseAmount(amount); + const parsedDays = parseDays(days, { min: spec.minDays }); + const amountErr = parsedAmount.ok ? null : AMOUNT_ERROR_MESSAGES[parsedAmount.reason]; + const daysErr = parsedDays.ok ? null : daysMessage(parsedDays.reason, spec.name, spec.minDays); + const amt = parsedAmount.ok ? parsedAmount.value : 0n; + const dur = parsedDays.ok ? parsedDays.value : 0; + const valid = parsedAmount.ok && parsedDays.ok; + const projected = valid ? yieldOf(amt, spec.apy, dur) : 0n; + const penalty = valid ? penaltyOf(amt, spec.penalty) : 0n; function pickType(t: CommitmentType) { setType(t); const min = specFor(t).minDays; - if (!Number.isInteger(Number(days)) || Number(days) < min) setDays(String(min)); + const current = parseDays(days); + if (!current.ok || current.value < min) setDays(String(min)); } function submit() { @@ -78,7 +100,7 @@ export default function CreatePage() { id: newId(), type, asset, - amount: amt, + amount: amt.toString(), durationDays: dur, maxLoss: spec.maxLoss, penalty: spec.penalty, @@ -192,6 +214,7 @@ export default function CreatePage() { setAmount(e.target.value)} onBlur={() => setAmountTouched(true)} @@ -200,7 +223,7 @@ export default function CreatePage() { /> - Enter a number greater than zero. + Digits and an optional decimal point, up to 7 decimals. {showAmountErr && ( @@ -214,6 +237,7 @@ export default function CreatePage() { setDays(e.target.value)} onBlur={() => setDaysTouched(true)} @@ -242,7 +266,7 @@ export default function CreatePage() {
  • Projected yield - {fmt(projected)} {asset} + {fmtUnits(projected)} {asset}
  • @@ -270,7 +294,7 @@ export default function CreatePage() {
  • Principal - {fmt(amt)} {asset} + {formatAmount(amt)} {asset}
  • @@ -284,13 +308,13 @@ export default function CreatePage() {
  • Projected yield - {fmt(projected)} {asset} + {formatAmount(projected)} {asset}
  • Early exit penalty - {spec.penalty}% ({fmt((amt * spec.penalty) / 100)} {asset}) + {spec.penalty}% ({formatAmount(penalty)} {asset})
  • @@ -323,7 +347,7 @@ export default function CreatePage() { Commitment created

    - {created.id} · {fmt(created.amount)} {created.asset} for {created.durationDays}{' '} + {created.id} · {formatAmount(BigInt(created.amount))} {created.asset} for {created.durationDays}{' '} days

    {ESCROW_CONTRACT_ID && ( diff --git a/src/app/dashboard/dashboard.a11y.test.tsx b/src/app/dashboard/dashboard.a11y.test.tsx index aa269fa9b..ff59b7729 100644 --- a/src/app/dashboard/dashboard.a11y.test.tsx +++ b/src/app/dashboard/dashboard.a11y.test.tsx @@ -39,8 +39,8 @@ async function seriousViolations() { beforeEach(() => { const now = Date.now(); seed = [ - { ...base, id: 'CMT-ACTIVE', amount: 1000, durationDays: 100, createdAt: now - 40 * DAY, status: 'active' }, - { ...base, id: 'CMT-MATURED', amount: 500, durationDays: 10, createdAt: now - 20 * DAY, status: 'active' }, + { ...base, id: 'CMT-ACTIVE', amount: '10000000000', durationDays: 100, createdAt: now - 40 * DAY, status: 'active' }, + { ...base, id: 'CMT-MATURED', amount: '5000000000', durationDays: 10, createdAt: now - 20 * DAY, status: 'active' }, ]; }); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index e2d3f0831..3fcfcc3f1 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -2,9 +2,11 @@ import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; +import { formatAmount, penaltyOf } from '@/lib/amount'; import { effectiveStatus, fmt, + fmtUnits, loadCommitments, maturityOf, progressOf, @@ -32,8 +34,9 @@ interface Notice { // Single source for the numbers shown in the exit dialog and applied to the commitment. function exitBreakdown(c: Commitment) { - const penaltyAmount = (c.amount * c.penalty) / 100; - return { principal: c.amount, penaltyPct: c.penalty, penaltyAmount, returned: c.amount - penaltyAmount }; + const principal = BigInt(c.amount); + const penaltyAmount = penaltyOf(principal, c.penalty); + return { principal, penaltyPct: c.penalty, penaltyAmount, returned: principal - penaltyAmount }; } type BalanceState = @@ -186,8 +189,8 @@ export default function DashboardPage() { const breakdown = target ? exitBreakdown(target) : null; const live = list.filter((c) => ['active', 'matured'].includes(effectiveStatus(c))); - const locked = live.reduce((s, c) => s + c.amount, 0); - const yieldTotal = live.reduce((s, c) => s + projectedYield(c), 0); + const locked = live.reduce((s, c) => s + BigInt(c.amount), 0n); + const yieldTotal = live.reduce((s, c) => s + projectedYield(c), 0n); const matured = list.filter((c) => effectiveStatus(c) === 'matured').length; return ( @@ -222,11 +225,11 @@ export default function DashboardPage() {
    Total locked - {fmt(locked)} + {fmtUnits(locked)}
    Projected yield - {fmt(yieldTotal)} + {fmtUnits(yieldTotal)}
    {status === 'connected' ? `Wallet XLM (${config.networkDetails.label.toLowerCase()})` : 'Ready to settle'} @@ -288,7 +291,7 @@ export default function DashboardPage() {

    - {fmt(c.amount)} {c.asset}{' '} + {fmtUnits(BigInt(c.amount))} {c.asset}{' '} {st} {c.demo && demo}

    @@ -350,10 +353,10 @@ 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` - : `Exited early, ${fmt(exitBreakdown(c).penaltyAmount)} ${c.asset} penalty paid`} + ? `Settled with ${formatAmount(projectedYield(c))} ${c.asset} yield` + : `Exited early, ${formatAmount(exitBreakdown(c).penaltyAmount)} ${c.asset} penalty paid`}

    ); @@ -381,17 +384,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/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 new file mode 100644 index 000000000..47f799d5b --- /dev/null +++ b/src/lib/__tests__/commitments.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + loadCommitments, + normalizeStoredAmount, + projectedYield, + resetCommitments, + sampleCommitments, + saveCommitments, + validateCommitment, +} 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 bigint basis point math on the stored base units', () => { + const c = validateCommitment(record({ amount: '10000000000', durationDays: 14 }))!; + // 1,000 at 9% for 14 days = 3.4520547... floored to 34_520_547 stroops + expect(projectedYield(c)).toBe(34_520_547n); + }); +}); + +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(); + }); +}); 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 99347acd4..bc61dc772 100644 --- a/src/lib/commitments.ts +++ b/src/lib/commitments.ts @@ -1,3 +1,4 @@ +import { STELLAR_DECIMALS, formatAmount, yieldOf } from './amount'; import { isStorageAvailable, readRaw, removeRaw, subscribeStorage, writeRaw } from './storage'; export type CommitmentType = 'safe' | 'balanced' | 'aggressive'; @@ -47,7 +48,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; @@ -73,7 +75,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; @@ -102,8 +104,8 @@ 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 { - return (c.amount * (c.apy / 100) * c.durationDays) / 365; +export function projectedYield(c: Commitment): bigint { + return yieldOf(BigInt(c.amount), c.apy, c.durationDays); } let fallbackCounter = 0; @@ -134,6 +136,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; } @@ -150,7 +167,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; @@ -166,7 +184,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, @@ -185,9 +203,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. */ @@ -296,7 +321,7 @@ export function sampleCommitments(): Commitment[] { id: newId(), type: 'balanced', asset: 'USDC', - amount: 2500, + amount: '25000000000', durationDays: 30, maxLoss: 8, penalty: 5, @@ -311,7 +336,7 @@ export function sampleCommitments(): Commitment[] { id: newId(), type: 'safe', asset: 'XLM', - amount: 10000, + amount: '100000000000', durationDays: 14, maxLoss: 2, penalty: 3, @@ -328,3 +353,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 }); +}