diff --git a/src/app/create/create.amount.test.tsx b/src/app/create/create.amount.test.tsx new file mode 100644 index 000000000..a1e8e5740 --- /dev/null +++ b/src/app/create/create.amount.test.tsx @@ -0,0 +1,133 @@ +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 }), +})); + +vi.mock('@/lib/protocol', () => { + const params = { + penalties: { safe: 200, balanced: 500, aggressive: 1000 }, + gracePeriodSeconds: 0, + isFallback: true, + }; + return { + FALLBACK_PROTOCOL_PARAMS: params, + useProtocolParams: () => ({ params, loading: false, isFallback: true, refresh: () => {} }), + }; +}); + +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], + ['0', /minimum lock is 1 day/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 balanced (700 bps) for 14 days = 2.6849315 (floored), penalty 5% = 50 + expect(screen.getByText('2.6849315 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 765679214..db06e049c 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -2,9 +2,17 @@ import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; +import { + AMOUNT_ERROR_MESSAGES, + MAX_AMOUNT, + formatAmount, + parseAmount, + parseDays, + type DaysError, +} from '@/lib/amount'; import { TYPES, - fmt, + fmtUnits, loadCommitments, newId, saveCommitments, @@ -17,11 +25,8 @@ import { ESCROW_CONTRACT_ID, explorerContractUrl, config } from '@/lib/config'; import { useProtocolParams } from '@/lib/protocol'; import { DISPLAY_APY, - MAX_AMOUNT_STROOPS, MAX_DURATION_DAYS, MIN_DURATION_DAYS, - formatStroops, - parseToStroops, projectYieldStroops, } from '@/lib/yield'; import { Skeleton } from '@/components/Skeleton'; @@ -29,6 +34,19 @@ import { Skeleton } from '@/components/Skeleton'; const ASSETS = ['USDC', 'XLM', 'EURC']; const STEP_LABELS = ['Type', 'Configure', 'Review', 'Done']; +function daysMessage(reason: DaysError, 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 is ${minDays} day${minDays === 1 ? '' : 's'}.`; + case 'above_max': + return `Maximum lock is ${MAX_DURATION_DAYS} days.`; + } +} + export default function CreatePage() { const { address } = useWallet(); const { params, loading: paramsLoading, isFallback } = useProtocolParams(); @@ -71,27 +89,16 @@ export default function CreatePage() { const penaltyBps = params.penalties[type]; const currentPenaltyPct = penaltyBps / 100; - const amt = Number(amount); - const dur = Number(days); - const stroops = parseToStroops(amount); - - let amountErr: string | null = null; - if (!Number.isFinite(amt) || amt <= 0 || stroops === null || stroops <= 0n) { - amountErr = 'Enter an amount greater than zero.'; - } else if (stroops > MAX_AMOUNT_STROOPS) { - amountErr = `Maximum commitment amount is ${formatStroops(MAX_AMOUNT_STROOPS)} ${asset}.`; - } - - let daysErr: string | null = null; - if (!Number.isInteger(dur) || dur < MIN_DURATION_DAYS) { - daysErr = `Minimum lock is ${MIN_DURATION_DAYS} day.`; - } else if (dur > MAX_DURATION_DAYS) { - daysErr = `Maximum lock is ${MAX_DURATION_DAYS} days.`; - } - - const valid = !amountErr && !daysErr && stroops !== null; - const projectedYieldStroopsVal = valid ? projectYieldStroops(stroops, dur, type) : 0n; - const penaltyStroopsVal = valid ? (stroops * BigInt(penaltyBps)) / 10_000n : 0n; + // Exact fixed-point parsing; yield and penalty use the contract's integer math. + const parsedAmount = parseAmount(amount); + const parsedDays = parseDays(days, { min: MIN_DURATION_DAYS, max: MAX_DURATION_DAYS }); + const amountErr = parsedAmount.ok ? null : AMOUNT_ERROR_MESSAGES[parsedAmount.reason]; + const daysErr = parsedDays.ok ? null : daysMessage(parsedDays.reason, MIN_DURATION_DAYS); + const amt = parsedAmount.ok ? parsedAmount.value : 0n; + const dur = parsedDays.ok ? parsedDays.value : 0; + const valid = parsedAmount.ok && parsedDays.ok; + const projected = valid ? projectYieldStroops(amt, dur, type) : 0n; + const penalty = valid ? (amt * BigInt(penaltyBps)) / 10_000n : 0n; function pickType(t: CommitmentType) { setType(t); @@ -102,7 +109,7 @@ export default function CreatePage() { id: newId(), type, asset, - amount: amt, + amount: amt.toString(), durationDays: dur, maxLoss: spec.maxLoss, penalty: currentPenaltyPct, @@ -221,6 +228,7 @@ export default function CreatePage() { setAmount(e.target.value)} onBlur={() => setAmountTouched(true)} @@ -229,7 +237,7 @@ export default function CreatePage() { /> - Enter a number greater than zero (max {formatStroops(MAX_AMOUNT_STROOPS)} {asset}). + Digits and an optional decimal point, up to 7 decimals (max {formatAmount(MAX_AMOUNT)} {asset}). {showAmountErr && ( @@ -243,6 +251,7 @@ export default function CreatePage() { setDays(e.target.value)} onBlur={() => setDaysTouched(true)} @@ -275,7 +284,7 @@ export default function CreatePage() {
  • Projected yield - {formatStroops(projectedYieldStroopsVal)} {asset} + {fmtUnits(projected)} {asset}
  • @@ -308,7 +317,7 @@ export default function CreatePage() {
  • Principal - {fmt(amt)} {asset} + {formatAmount(amt)} {asset}
  • @@ -322,7 +331,7 @@ export default function CreatePage() {
  • Projected yield - {formatStroops(projectedYieldStroopsVal)} {asset} + {formatAmount(projected)} {asset}
  • @@ -331,7 +340,7 @@ export default function CreatePage() { ) : ( - {currentPenaltyPct}% ({formatStroops(penaltyStroopsVal)} {asset}) + {currentPenaltyPct}% ({formatAmount(penalty)} {asset}) )}
  • @@ -365,7 +374,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/__tests__/dashboard.test.tsx b/src/app/dashboard/__tests__/dashboard.test.tsx index e40e6d2ef..3408e1f5e 100644 --- a/src/app/dashboard/__tests__/dashboard.test.tsx +++ b/src/app/dashboard/__tests__/dashboard.test.tsx @@ -43,8 +43,8 @@ let seed: Commitment[] = []; beforeEach(() => { seed = [ - { ...base, id: 'CMT-DISPUTED', amount: 500, durationDays: 30, createdAt: now - 10 * DAY, status: 'disputed' } as Commitment, - { ...base, id: 'CMT-ACTIVE', amount: 1000, durationDays: 100, createdAt: now - 40 * DAY, status: 'active' } as Commitment, + { ...base, id: 'CMT-DISPUTED', amount: '5000000000', durationDays: 30, createdAt: now - 10 * DAY, status: 'disputed' } as Commitment, + { ...base, id: 'CMT-ACTIVE', amount: '10000000000', durationDays: 100, createdAt: now - 40 * DAY, status: 'active' } as Commitment, ]; vi.mocked(getDispute).mockResolvedValue(null); vi.mocked(openDispute).mockResolvedValue({ transactionHash: 'abc' }); 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 ca4f3ab84..f8a06b3ec 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, @@ -35,8 +37,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 = @@ -244,8 +247,8 @@ export default function DashboardPage() { const live = list.filter((c) => ['active', 'matured'].includes(effectiveStatus(c))); const frozen = list.filter((c) => effectiveStatus(c) === 'disputed' || effectiveStatus(c) === 'violated'); - const locked = [...live, ...frozen].reduce((s, c) => s + c.amount, 0); - const yieldTotal = [...live, ...frozen].reduce((s, c) => s + projectedYield(c), 0); + const locked = [...live, ...frozen].reduce((s, c) => s + BigInt(c.amount), 0n); + const yieldTotal = [...live, ...frozen].reduce((s, c) => s + projectedYield(c), 0n); const matured = list.filter((c) => effectiveStatus(c) === 'matured').length; return ( @@ -286,11 +289,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'} @@ -355,7 +358,7 @@ export default function DashboardPage() {

    - {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 }); +}