Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/app/create/create.amount.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatePage />);
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');
});
});
73 changes: 41 additions & 32 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,18 +25,28 @@ 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';

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();
Expand Down Expand Up @@ -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);
Expand All @@ -102,7 +109,7 @@ export default function CreatePage() {
id: newId(),
type,
asset,
amount: amt,
amount: amt.toString(),
durationDays: dur,
maxLoss: spec.maxLoss,
penalty: currentPenaltyPct,
Expand Down Expand Up @@ -221,6 +228,7 @@ export default function CreatePage() {
<input
ref={amountRef}
inputMode="decimal"
pattern="\d+(\.\d+)?"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onBlur={() => setAmountTouched(true)}
Expand All @@ -229,7 +237,7 @@ export default function CreatePage() {
/>
</label>
<small id="amount" className="muted">
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}).
</small>
{showAmountErr && (
<small id="amount-err" className="err">
Expand All @@ -243,6 +251,7 @@ export default function CreatePage() {
<input
ref={daysRef}
inputMode="numeric"
pattern="\d+"
value={days}
onChange={(e) => setDays(e.target.value)}
onBlur={() => setDaysTouched(true)}
Expand Down Expand Up @@ -275,7 +284,7 @@ export default function CreatePage() {
<li>
<span>Projected yield</span>
<b>
{formatStroops(projectedYieldStroopsVal)} {asset}
{fmtUnits(projected)} {asset}
</b>
</li>
</ul>
Expand Down Expand Up @@ -308,7 +317,7 @@ export default function CreatePage() {
<li>
<span>Principal</span>
<b>
{fmt(amt)} {asset}
{formatAmount(amt)} {asset}
</b>
</li>
<li>
Expand All @@ -322,7 +331,7 @@ export default function CreatePage() {
<li>
<span>Projected yield</span>
<b>
{formatStroops(projectedYieldStroopsVal)} {asset}
{formatAmount(projected)} {asset}
</b>
</li>
<li>
Expand All @@ -331,7 +340,7 @@ export default function CreatePage() {
<Skeleton width="120px" height="20px" />
) : (
<b>
{currentPenaltyPct}% ({formatStroops(penaltyStroopsVal)} {asset})
{currentPenaltyPct}% ({formatAmount(penalty)} {asset})
</b>
)}
</li>
Expand Down Expand Up @@ -365,7 +374,7 @@ export default function CreatePage() {
Commitment created
</h2>
<p className="muted">
{created.id} &middot; {fmt(created.amount)} {created.asset} for {created.durationDays}{' '}
{created.id} &middot; {formatAmount(BigInt(created.amount))} {created.asset} for {created.durationDays}{' '}
days
</p>
{ESCROW_CONTRACT_ID && (
Expand Down
4 changes: 2 additions & 2 deletions src/app/dashboard/__tests__/dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
4 changes: 2 additions & 2 deletions src/app/dashboard/dashboard.a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];
});

Expand Down
Loading