diff --git a/src/app/commitments/[id]/__tests__/page.test.tsx b/src/app/commitments/[id]/__tests__/page.test.tsx new file mode 100644 index 00000000..b3d7a289 --- /dev/null +++ b/src/app/commitments/[id]/__tests__/page.test.tsx @@ -0,0 +1,248 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CommitmentDetailPage from '../page'; +import * as readModule from '@/lib/soroban/read'; +import { + formatRelativeTime, + formatAbsoluteTime, + shortenAddress, +} from '@/lib/soroban/read'; +import { EscrowErrorCode, TxError } from '@/lib/soroban/errors'; + +vi.mock('@/lib/soroban/read', async () => { + const actual = await vi.importActual('@/lib/soroban/read'); + return { + ...actual, + getCommitment: vi.fn(), + getAttestations: vi.fn(), + getDispute: vi.fn(), + }; +}); + +describe('CommitmentDetailPage Component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ID Validation & Invalid State', () => { + it('shows validation error without calling RPC for non-numeric ID "abc"', async () => { + render(); + + expect(screen.getByText('Invalid Commitment ID')).toBeTruthy(); + expect(screen.getByText(/Commitment ID must be a non-negative integer/i)).toBeTruthy(); + expect(readModule.getCommitment).not.toHaveBeenCalled(); + }); + + it('shows validation error without calling RPC for negative ID "-1"', async () => { + render(); + + expect(screen.getByText('Invalid Commitment ID')).toBeTruthy(); + expect(readModule.getCommitment).not.toHaveBeenCalled(); + }); + + it('shows validation error without calling RPC for out-of-range 2^64 ID', async () => { + render(); + + expect(screen.getByText('Invalid Commitment ID')).toBeTruthy(); + expect(screen.getByText(/out of range/i)).toBeTruthy(); + expect(readModule.getCommitment).not.toHaveBeenCalled(); + }); + }); + + describe('Not-Found State', () => { + it('displays Not-Found UI when contract returns NotFound error code 3', async () => { + const notFoundErr = new TxError('contract', 'The requested commitment could not be found.', { + code: EscrowErrorCode.NotFound, + }); + vi.mocked(readModule.getCommitment).mockRejectedValue(notFoundErr); + vi.mocked(readModule.getAttestations).mockResolvedValue([]); + vi.mocked(readModule.getDispute).mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByText('Commitment Not Found')).toBeTruthy(); + }); + + expect(screen.getByText(/No on-chain commitment exists with ID/i)).toBeTruthy(); + expect(screen.getByText('#9999')).toBeTruthy(); + expect(screen.getByRole('link', { name: /Return to Dashboard/i })).toBeTruthy(); + }); + }); + + describe('Error State & Retry Action', () => { + it('displays error UI and retries on button click', async () => { + const user = userEvent.setup(); + vi.mocked(readModule.getCommitment) + .mockRejectedValueOnce(new Error('Stellar RPC node timed out')) + .mockResolvedValueOnce({ + id: '12', + owner: 'GOWNER1111111111111111111111111111111111111111111111111', + asset: 'CASSET1111111111111111111111111111111111111111111111111', + amount: 1000000000n, + accruedYield: 50000000n, + risk: 'Balanced', + status: 'Funded', + maturity: 1735000000n, + penaltyBps: 300, + complianceScore: 90, + createdAt: 1732000000n, + metadata: {}, + }); + vi.mocked(readModule.getAttestations).mockResolvedValue([]); + vi.mocked(readModule.getDispute).mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error Loading Commitment')).toBeTruthy(); + }); + expect(screen.getByText('Stellar RPC node timed out')).toBeTruthy(); + + const retryBtn = screen.getByRole('button', { name: /Try Again/i }); + await user.click(retryBtn); + + await waitFor(() => { + expect(screen.getByText('Commitment #12')).toBeTruthy(); + }); + expect(readModule.getCommitment).toHaveBeenCalledTimes(2); + }); + }); + + describe('Loaded State (Full Record with Attestations & Dispute)', () => { + const mockCommitment: readModule.OnChainCommitment = { + id: '0', + owner: 'GOWNER12345678901234567890123456789012345678901234567890', + asset: 'CASSET12345678901234567890123456789012345678901234567890', + amount: 5000000000n, + accruedYield: 250000000n, + risk: 'Balanced', + status: 'Funded', + maturity: 1735689600n, + penaltyBps: 300, + complianceScore: 92, + createdAt: 1733097600n, + metadata: { + note: 'Seed escrow investment', + category: 'DeFi-Vault', + }, + }; + + const mockAttestations: readModule.OnChainAttestation[] = [ + { + attestor: 'GATTESTOR11111111111111111111111111111111111111111111111', + complianceScore: 92, + timestamp: 1733500000n, + }, + ]; + + const mockDispute: readModule.OnChainDispute = { + reasonCategory: 'Fraud Suspicion', + reasonCode: 2, + reasonText: 'Flagged suspicious activity on destination wallet.', + disputedAt: 1734000000n, + disputedBy: 'GADMIN1111111111111111111111111111111111111111111111111', + }; + + it('renders commitment terms, compliance score, and timeline', async () => { + vi.mocked(readModule.getCommitment).mockResolvedValue(mockCommitment); + vi.mocked(readModule.getAttestations).mockResolvedValue(mockAttestations); + vi.mocked(readModule.getDispute).mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByText('Commitment #0')).toBeTruthy(); + }); + + expect(screen.getByText('5,000,000,000')).toBeTruthy(); + expect(screen.getByText('+250,000,000')).toBeTruthy(); + expect(screen.getAllByText('92 / 100')).toHaveLength(2); + expect(screen.getByText(/3.00% \(300 basis points\)/i)).toBeTruthy(); + + // Attestations section + expect(screen.getByText('Compliance Attestations')).toBeTruthy(); + expect(screen.getByText('GATTES...111111')).toBeTruthy(); + + // Metadata section + expect(screen.getByText('Commitment Metadata')).toBeTruthy(); + expect(screen.getByText('note')).toBeTruthy(); + expect(screen.getByText('Seed escrow investment')).toBeTruthy(); + + // When no dispute, Dispute section should NOT be in the document + expect(screen.queryByText('Active Dispute')).toBeNull(); + }); + + it('renders Dispute section when dispute is Some', async () => { + vi.mocked(readModule.getCommitment).mockResolvedValue({ + ...mockCommitment, + status: 'Disputed', + }); + vi.mocked(readModule.getAttestations).mockResolvedValue([]); + vi.mocked(readModule.getDispute).mockResolvedValue(mockDispute); + + render(); + + await waitFor(() => { + expect(screen.getByText('Active Dispute')).toBeTruthy(); + }); + + expect(screen.getByText('Fraud Suspicion')).toBeTruthy(); + expect(screen.getByText('Flagged suspicious activity on destination wallet.')).toBeTruthy(); + expect(screen.getByText(mockDispute.disputedBy)).toBeTruthy(); + expect(screen.getByText('No compliance attestations recorded yet.')).toBeTruthy(); + }); + + it('renders text content safely without injecting HTML', async () => { + const xssDispute: readModule.OnChainDispute = { + reasonCategory: 'Other', + reasonCode: 4, + reasonText: '', + disputedAt: 1734000000n, + disputedBy: 'GADMIN1111111111111111111111111111111111111111111111111', + }; + + vi.mocked(readModule.getCommitment).mockResolvedValue({ + ...mockCommitment, + metadata: { + xssPayload: '', + }, + }); + vi.mocked(readModule.getAttestations).mockResolvedValue([]); + vi.mocked(readModule.getDispute).mockResolvedValue(xssDispute); + + render(); + + await waitFor(() => { + expect(screen.getByText('')).toBeTruthy(); + }); + expect(screen.getByText('')).toBeTruthy(); + // Ensure it is rendered as text and no script tags executed + expect(document.querySelector('script:not([src])')).toBeNull(); + }); + }); + + describe('Formatting Utilities', () => { + it('formats relative time accurately', () => { + const now = 1700000000000; + expect(formatRelativeTime(1699999990n, now)).toBe('10s ago'); + expect(formatRelativeTime(1699999800n, now)).toBe('3m ago'); + expect(formatRelativeTime(1699992800n, now)).toBe('2h ago'); + expect(formatRelativeTime(1699740800n, now)).toBe('3d ago'); + }); + + it('formats absolute time in UTC', () => { + expect(formatAbsoluteTime(1700000000n)).toBe('2023-11-14 22:13:20 UTC'); + expect(formatAbsoluteTime(0n)).toBe('Not set'); + }); + + it('shortens Stellar addresses', () => { + expect(shortenAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF')).toBe( + 'GAAAAA...AAAWHF', + ); + expect(shortenAddress('SHORT')).toBe('SHORT'); + }); + }); +}); diff --git a/src/app/commitments/[id]/page.tsx b/src/app/commitments/[id]/page.tsx new file mode 100644 index 00000000..32b8cda0 --- /dev/null +++ b/src/app/commitments/[id]/page.tsx @@ -0,0 +1,626 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { + validateCommitmentId, + getCommitment, + getAttestations, + getDispute, + isNotFoundError, + formatRelativeTime, + formatAbsoluteTime, + shortenAddress, + OnChainCommitment, + OnChainAttestation, + OnChainDispute, +} from '@/lib/soroban/read'; +import { + config, + ESCROW_CONTRACT_ID, + explorerAccount, + explorerContractUrl, +} from '@/lib/config'; +import { Skeleton } from '@/components/Skeleton'; + +interface PageProps { + params: { + id: string; + }; +} + +export default function CommitmentDetailPage({ params }: PageProps) { + const rawId = params?.id ?? ''; + const validation = validateCommitmentId(rawId); + + const [loading, setLoading] = useState(validation.valid); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + + const [commitment, setCommitment] = useState(null); + const [attestations, setAttestations] = useState([]); + const [dispute, setDispute] = useState(null); + + const fetchData = useCallback(async () => { + if (!validation.valid) { + setLoading(false); + return; + } + + setLoading(true); + setNotFound(false); + setError(null); + + try { + const [cRecord, aRecords, dRecord] = await Promise.all([ + getCommitment(rawId), + getAttestations(rawId).catch(() => []), + getDispute(rawId).catch(() => null), + ]); + + setCommitment(cRecord); + setAttestations(aRecords); + setDispute(dRecord); + } catch (err: unknown) { + if (isNotFoundError(err)) { + setNotFound(true); + } else { + const msg = err instanceof Error ? err.message : String(err); + setError(msg || 'Failed to load commitment from on-chain escrow.'); + } + } finally { + setLoading(false); + } + }, [rawId, validation.valid]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // State 1: Invalid ID format (Validation failure without calling RPC) + if (!validation.valid) { + return ( +
+
+ +

Invalid Commitment ID

+

+ {validation.error} +

+
+ + ← Back to Dashboard + +
+
+
+ ); + } + + // State 2: Loading Skeleton + if (loading) { + return ( +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ +
+ +
+ +
+
+
+ ); + } + + // State 3: Not-Found State + if (notFound) { + return ( +
+
+ +

Commitment Not Found

+

+ No on-chain commitment exists with ID #{rawId} on the configured escrow contract. +

+

+ It may have never been created, or was deployed to a different contract/network. +

+
+ + ← Return to Dashboard + + + View Activity Log + +
+
+
+ ); + } + + // State 4: Generic Error State with Retry + if (error || !commitment) { + return ( +
+
+ +

Error Loading Commitment

+

+ {error || 'Unable to retrieve commitment record.'} +

+
+ + + Back to Dashboard + +
+
+
+ ); + } + + // State 5: Loaded Commitment Detail View + const st = commitment.status.toLowerCase(); + const metadataEntries = Object.entries(commitment.metadata); + + return ( +
+ {/* Header & Status Bar */} +
+
+
+

Commitment #{commitment.id}

+ + {commitment.status} + +
+

+ On-chain escrow record · Network:{' '} + {config.network} +

+
+
+ + ← Dashboard + + {ESCROW_CONTRACT_ID && ( + + Contract Explorer → + + )} +
+
+ + {/* Top Metric Cards */} +
+
+ LOCKED PRINCIPAL + + {commitment.amount.toLocaleString('en-US')} + + Asset: {shortenAddress(commitment.asset)} +
+ +
+ ACCRUED YIELD + + +{commitment.accruedYield.toLocaleString('en-US')} + + Escrow yield allocation +
+ +
+ COMPLIANCE SCORE + = 80 + ? 'var(--ok)' + : commitment.complianceScore >= 50 + ? 'var(--warn)' + : 'var(--danger)', + }} + > + {commitment.complianceScore} / 100 + + + {attestations.length} attestation{attestations.length === 1 ? '' : 's'} recorded + +
+ +
+ RISK & PENALTY + {commitment.risk} + + Penalty: {(commitment.penaltyBps / 100).toFixed(2)}% ({commitment.penaltyBps} bps) + +
+
+ + {/* Section: Terms & Specifications */} +
+

Commitment Terms

+
    +
  • + Commitment ID + #{commitment.id} +
  • +
  • + Owner Address + + + {commitment.owner} + + +
  • +
  • + Asset Contract + + + {commitment.asset} + + +
  • +
  • + Locked Principal + {commitment.amount.toLocaleString('en-US')} units +
  • +
  • + Accrued Yield + + +{commitment.accruedYield.toLocaleString('en-US')} units + +
  • +
  • + Risk Profile + {commitment.risk} +
  • +
  • + Early Exit Penalty + + {(commitment.penaltyBps / 100).toFixed(2)}% ({commitment.penaltyBps} basis points) + +
  • +
  • + Created At + {formatAbsoluteTime(commitment.createdAt)} +
  • +
  • + Maturity Date + {formatAbsoluteTime(commitment.maturity)} +
  • +
+
+ + {/* Section: Timeline & Status Overview */} +
+

Status & Lifecycle Timeline

+
+
+
STEP 1
+
Created
+
{formatAbsoluteTime(commitment.createdAt)}
+
+ +
+
STEP 2
+
Funded
+
+ {st === 'created' ? 'Awaiting initial deposit' : 'Escrow funded & held'} +
+
+ +
+
STEP 3
+
Current: {commitment.status}
+
+ {st === 'released' + ? 'Matured and released to owner' + : st === 'refunded' + ? 'Exited early with penalty paid' + : st === 'disputed' + ? 'Under formal dispute — transfers frozen' + : st === 'violated' + ? 'Compliance violation triggered' + : st === 'cancelled' + ? 'Cancelled prior to funding' + : 'Active lock until maturity'} +
+
+
+
+ + {/* Section: Dispute Notice (Rendered ONLY when dispute exists) */} + {dispute && ( +
+
+

+ Active Dispute +

+ DISPUTED +
+

+ This commitment is under formal dispute on-chain. State-changing token releases are + frozen until administrative resolution. +

+ +
    +
  • + Reason Category + {dispute.reasonCategory} +
  • +
  • + Opened At + {formatAbsoluteTime(dispute.disputedAt)} +
  • +
  • + Disputed By + + + {dispute.disputedBy} + + +
  • +
  • + Reason Details +

    + {dispute.reasonText || 'No detail string provided.'} +

    +
  • +
+
+ )} + + {/* Section: Compliance & Attestations */} +
+
+

+ Compliance Attestations +

+ + {attestations.length} total attestation{attestations.length === 1 ? '' : 's'} + +
+ + {attestations.length === 0 ? ( +
+

No compliance attestations recorded yet.

+

+ Attestations recorded by authorized oracles or keepers will appear here in chronological + order. +

+
+ ) : ( +
+ + + + + + + + + + + {attestations.map((att, idx) => ( + + + + + + + ))} + +
AttestorScoreRecorded (Relative)Timestamp (UTC)
+ + + {shortenAddress(att.attestor)} + + + + = 80 + ? 'var(--ok)' + : att.complianceScore >= 50 + ? 'var(--warn)' + : 'var(--danger)', + color: + att.complianceScore >= 80 + ? 'var(--ok)' + : att.complianceScore >= 50 + ? 'var(--warn)' + : 'var(--danger)', + }} + > + {att.complianceScore} / 100 + + {formatRelativeTime(att.timestamp)}{formatAbsoluteTime(att.timestamp)}
+
+ )} +
+ + {/* Section: Metadata (Arbitrary key/value pairs) */} + {metadataEntries.length > 0 && ( +
+

Commitment Metadata

+
    + {metadataEntries.map(([key, val]) => ( +
  • + {key} + + {val} + +
  • + ))} +
+
+ )} + + {/* Section: Protocol Context */} +
+

Contract Verification

+
    +
  • + Escrow Contract + + {ESCROW_CONTRACT_ID ? ( + + {ESCROW_CONTRACT_ID} + + ) : ( + 'Unconfigured' + )} + +
  • +
  • + Network Passphrase + {config.passphrase} +
  • +
  • + Soroban RPC Endpoint + {config.sorobanRpcUrl} +
  • +
+
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b8f7333c..ca4f3ab8 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -360,7 +360,18 @@ export default function DashboardPage() { {c.demo && demo}

- {c.id} · {specFor(c.type).name} · {c.apy}% APY · max + {/^\d+$/.test(c.id) ? ( + + #{c.id} + + ) : ( + {c.id} + )}{' '} + · {specFor(c.type).name} · {c.apy}% APY · max loss {c.maxLoss}%

{frozen && record && ( @@ -551,7 +562,17 @@ export default function DashboardPage() {
{ev.kind} - {ev.commitmentId ? `Commitment #${ev.commitmentId}` : 'Contract Action'} + {ev.commitmentId ? ( + + Commitment #{ev.commitmentId} + + ) : ( + 'Contract Action' + )} {ev.amount && {fmt(Number(ev.amount))} XLM}
diff --git a/src/app/globals.css b/src/app/globals.css index da1a9684..a371d98e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -111,6 +111,13 @@ h2:focus { outline: none; } .badge.disputed { color: var(--warn); border-color: var(--warn); } .badge.violated { color: var(--danger); border-color: var(--danger); } .badge.demo { color: var(--accent2); border-color: var(--accent2); } +.badge.created { color: var(--accent2); border-color: var(--accent2); } +.badge.funded { color: var(--accent); border-color: var(--accent); } +.badge.released { color: var(--ok); border-color: var(--ok); } +.badge.refunded { color: var(--warn); border-color: var(--warn); } +.badge.disputed { color: var(--danger); border-color: var(--danger); } +.badge.violated { color: var(--danger); border-color: var(--danger); } +.badge.cancelled { color: var(--muted); border-color: var(--muted); } .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .dialog { max-width: 440px; width: calc(100% - 32px); background: var(--bg2); color: var(--text); border: 1px solid var(--line); border-radius: 14px; padding: 24px; } .dialog::backdrop { background: rgba(0, 0, 0, .6); } diff --git a/src/lib/soroban/__tests__/read.test.ts b/src/lib/soroban/__tests__/read.test.ts new file mode 100644 index 00000000..2cb2c6eb --- /dev/null +++ b/src/lib/soroban/__tests__/read.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { nativeToScVal } from '@stellar/stellar-sdk'; +import { + validateCommitmentId, + getCommitment, + getAttestations, + getDispute, + isNotFoundError, + normalizeCommitment, + normalizeAttestation, + normalizeDispute, + MAX_U64, +} from '../read'; +import { EscrowErrorCode, TxError } from '../errors'; + +describe('validateCommitmentId', () => { + it('accepts valid decimal u64 strings', () => { + expect(validateCommitmentId('0')).toEqual({ valid: true, value: 0n }); + expect(validateCommitmentId('1')).toEqual({ valid: true, value: 1n }); + expect(validateCommitmentId('42')).toEqual({ valid: true, value: 42n }); + expect(validateCommitmentId('1000000')).toEqual({ valid: true, value: 1000000n }); + expect(validateCommitmentId(MAX_U64.toString())).toEqual({ valid: true, value: MAX_U64 }); + }); + + it('trims whitespace and validates', () => { + expect(validateCommitmentId(' 123 ')).toEqual({ valid: true, value: 123n }); + }); + + it('rejects empty or whitespace-only inputs without RPC calls', () => { + expect(validateCommitmentId('')).toMatchObject({ valid: false, error: expect.stringContaining('cannot be empty') }); + expect(validateCommitmentId(' ')).toMatchObject({ valid: false, error: expect.stringContaining('cannot be empty') }); + }); + + it('rejects non-numeric inputs', () => { + expect(validateCommitmentId('abc')).toMatchObject({ valid: false, error: expect.stringContaining('non-negative integer') }); + expect(validateCommitmentId('0x10')).toMatchObject({ valid: false, error: expect.stringContaining('non-negative integer') }); + expect(validateCommitmentId('123a')).toMatchObject({ valid: false, error: expect.stringContaining('non-negative integer') }); + }); + + it('rejects negative numbers and decimals', () => { + expect(validateCommitmentId('-1')).toMatchObject({ valid: false, error: expect.stringContaining('non-negative integer') }); + expect(validateCommitmentId('1.5')).toMatchObject({ valid: false, error: expect.stringContaining('non-negative integer') }); + }); + + it('rejects numbers strictly greater than u64 max (2^64 - 1)', () => { + const overflow = (MAX_U64 + 1n).toString(); + expect(validateCommitmentId(overflow)).toMatchObject({ valid: false, error: expect.stringContaining('out of range') }); + expect(validateCommitmentId('999999999999999999999999999')).toMatchObject({ valid: false, error: expect.stringContaining('out of range') }); + }); +}); + +describe('Soroban read helpers with mocked simulation', () => { + let mockServer: { simulateTransaction: ReturnType }; + + beforeEach(() => { + mockServer = { + simulateTransaction: vi.fn(), + }; + }); + + describe('getCommitment', () => { + it('returns a normalized on-chain commitment record', async () => { + const rawCommitment = { + id: 0n, + owner: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + asset: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + amount: 5000000000n, + accrued_yield: 250000000n, + risk: 'Balanced', + status: 'Funded', + maturity: 1735689600n, + penalty_bps: 300, + compliance_score: 95, + created_at: 1733097600n, + metadata: new Map([ + ['purpose', 'liquidity-escrow'], + ['tier', 'gold'], + ]), + }; + + mockServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal(rawCommitment), + }, + }); + + const res = await getCommitment('0', mockServer); + + expect(res.id).toBe('0'); + expect(res.owner).toBe('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'); + expect(res.asset).toBe('CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'); + expect(res.amount).toBe(5000000000n); + expect(res.accruedYield).toBe(250000000n); + expect(res.risk).toBe('Balanced'); + expect(res.status).toBe('Funded'); + expect(res.maturity).toBe(1735689600n); + expect(res.penaltyBps).toBe(300); + expect(res.complianceScore).toBe(95); + expect(res.createdAt).toBe(1733097600n); + expect(res.metadata).toEqual({ + purpose: 'liquidity-escrow', + tier: 'gold', + }); + }); + + it('throws NotFound error when contract returns NotFound (error code 3)', async () => { + mockServer.simulateTransaction.mockResolvedValue({ + error: 'HostError: Error(Contract, #3)', + }); + + await expect(getCommitment('999', mockServer)).rejects.toThrow(); + + try { + await getCommitment('999', mockServer); + } catch (err) { + expect(isNotFoundError(err)).toBe(true); + } + }); + + it('throws when simulation returns no return value and no error', async () => { + mockServer.simulateTransaction.mockResolvedValue({ + result: null, + }); + + await expect(getCommitment('1', mockServer)).rejects.toThrow(); + }); + }); + + describe('getAttestations', () => { + it('returns empty list when no attestations are recorded', async () => { + mockServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal([]), + }, + }); + + const res = await getAttestations('0', mockServer); + expect(res).toEqual([]); + }); + + it('returns non-empty attestations sorted newest first', async () => { + const rawRecords = [ + { + attestor: 'GATTESTOR11111111111111111111111111111111111111111111111', + compliance_score: 80, + timestamp: 1700000100n, // older + }, + { + attestor: 'GATTESTOR22222222222222222222222222222222222222222222222', + compliance_score: 95, + timestamp: 1700000500n, // newest + }, + { + attestor: 'GATTESTOR33333333333333333333333333333333333333333333333', + compliance_score: 90, + timestamp: 1700000300n, // middle + }, + ]; + + mockServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal(rawRecords), + }, + }); + + const res = await getAttestations('0', mockServer); + expect(res).toHaveLength(3); + // Verify sorted newest first + expect(res[0].timestamp).toBe(1700000500n); + expect(res[0].attestor).toBe('GATTESTOR22222222222222222222222222222222222222222222222'); + expect(res[0].complianceScore).toBe(95); + + expect(res[1].timestamp).toBe(1700000300n); + expect(res[1].complianceScore).toBe(90); + + expect(res[2].timestamp).toBe(1700000100n); + expect(res[2].complianceScore).toBe(80); + }); + }); + + describe('getDispute', () => { + it('returns null when commitment has no dispute (None)', async () => { + mockServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal(null), + }, + }); + + const res = await getDispute('0', mockServer); + expect(res).toBeNull(); + }); + + it('returns normalized dispute record when dispute exists (Some)', async () => { + const rawDispute = { + reason_category: 2, // FraudSuspicion + reason_text: 'Unauthorized transfer attempt flagged by sentinel', + disputed_at: 1734000000n, + disputed_by: 'GADMIN1111111111111111111111111111111111111111111111111', + }; + + mockServer.simulateTransaction.mockResolvedValue({ + result: { + retval: nativeToScVal(rawDispute), + }, + }); + + const res = await getDispute('0', mockServer); + expect(res).not.toBeNull(); + expect(res?.reasonCategory).toBe('Fraud Suspicion'); + expect(res?.reasonCode).toBe(2); + expect(res?.reasonText).toBe('Unauthorized transfer attempt flagged by sentinel'); + expect(res?.disputedAt).toBe(1734000000n); + expect(res?.disputedBy).toBe('GADMIN1111111111111111111111111111111111111111111111111'); + }); + }); + + describe('isNotFoundError', () => { + it('identifies TxError with code 3 as NotFound', () => { + const err = new TxError('contract', 'The requested commitment could not be found.', { + code: EscrowErrorCode.NotFound, + }); + expect(isNotFoundError(err)).toBe(true); + }); + + it('identifies errors with NotFound or #3 message text', () => { + expect(isNotFoundError(new Error('HostError: Error(Contract, #3)'))).toBe(true); + expect(isNotFoundError(new Error('Commitment not found on chain'))).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(isNotFoundError(new Error('Network timeout'))).toBe(false); + expect(isNotFoundError(null)).toBe(false); + expect(isNotFoundError(undefined)).toBe(false); + }); + }); +}); diff --git a/src/lib/soroban/read.ts b/src/lib/soroban/read.ts new file mode 100644 index 00000000..94e54110 --- /dev/null +++ b/src/lib/soroban/read.ts @@ -0,0 +1,381 @@ +import { + Account, + Contract, + nativeToScVal, + rpc, + scValToNative, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; +import { config, ESCROW_CONTRACT_ID, SOROBAN_RPC_URL } from '../config'; +import { EscrowErrorCode, toTxError, TxError } from './errors'; + +export const MAX_U64 = 18446744073709551615n; + +export interface ValidationResult { + valid: boolean; + error?: string; + value?: bigint; +} + +/** + * Validates a commitment ID string as an unsigned 64-bit integer (u64). + * Returns without invoking any RPC or network call. + */ +export function validateCommitmentId(id: string): ValidationResult { + const trimmed = (id ?? '').trim(); + if (!trimmed) { + return { valid: false, error: 'Commitment ID cannot be empty.' }; + } + if (!/^\d+$/.test(trimmed)) { + return { + valid: false, + error: `Invalid commitment ID "${id}". Commitment ID must be a non-negative integer (e.g. 0, 1, 42).`, + }; + } + try { + const val = BigInt(trimmed); + if (val < 0n || val > MAX_U64) { + return { + valid: false, + error: `Commitment ID "${id}" is out of range. Allowed values are 0 to 18,446,744,073,709,551,615 (u64).`, + }; + } + return { valid: true, value: val }; + } catch { + return { valid: false, error: `Invalid commitment ID "${id}". Could not parse as u64.` }; + } +} + +export type RiskProfileName = 'Safe' | 'Balanced' | 'Aggressive' | string; +export type EscrowStatusName = + | 'Created' + | 'Funded' + | 'Released' + | 'Refunded' + | 'Disputed' + | 'Violated' + | 'Cancelled' + | string; + +export interface OnChainCommitment { + id: string; + owner: string; + asset: string; + amount: bigint; + accruedYield: bigint; + risk: RiskProfileName; + status: EscrowStatusName; + maturity: bigint; // timestamp in seconds + penaltyBps: number; + complianceScore: number; + createdAt: bigint; // timestamp in seconds + metadata: Record; +} + +export interface OnChainAttestation { + attestor: string; + complianceScore: number; + timestamp: bigint; // timestamp in seconds +} + +export interface OnChainDispute { + reasonCategory: string; + reasonCode: number; + reasonText: string; + disputedAt: bigint; // timestamp in seconds + disputedBy: string; +} + +export interface ReadOptions { + server?: rpc.Server | { simulateTransaction: (tx: any) => Promise }; + contractId?: string; +} + +const DISPUTE_REASON_NAMES: Record = { + 0: 'Value Mismatch', + 1: 'Non-Compliance', + 2: 'Fraud Suspicion', + 3: 'Operational Failure', + 4: 'Other', +}; + +function normalizeEnumString(val: any, fallback: string): string { + if (typeof val === 'string') return val; + if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') return val[0]; + if (val && typeof val === 'object') { + if ('name' in val && typeof val.name === 'string') return val.name; + const keys = Object.keys(val); + if (keys.length > 0) return keys[0]; + } + return fallback; +} + +function normalizeMetadata(raw: any): Record { + if (!raw) return {}; + if (raw instanceof Map) { + const res: Record = {}; + for (const [k, v] of raw.entries()) { + res[String(k)] = String(v); + } + return res; + } + if (Array.isArray(raw)) { + const res: Record = {}; + for (const item of raw) { + if (Array.isArray(item) && item.length >= 2) { + res[String(item[0])] = String(item[1]); + } else if (item && typeof item === 'object' && 'key' in item && 'val' in item) { + res[String(item.key)] = String(item.val); + } + } + return res; + } + if (typeof raw === 'object') { + const res: Record = {}; + for (const [k, v] of Object.entries(raw)) { + res[k] = String(v); + } + return res; + } + return {}; +} + +export function normalizeCommitment(raw: any): OnChainCommitment { + return { + id: String(raw.id ?? '0'), + owner: String(raw.owner ?? ''), + asset: String(raw.asset ?? ''), + amount: typeof raw.amount === 'bigint' ? raw.amount : BigInt(raw.amount ?? 0), + accruedYield: + typeof raw.accrued_yield === 'bigint' + ? raw.accrued_yield + : typeof raw.accruedYield === 'bigint' + ? raw.accruedYield + : BigInt(raw.accrued_yield ?? raw.accruedYield ?? 0), + risk: normalizeEnumString(raw.risk, 'Balanced'), + status: normalizeEnumString(raw.status, 'Created'), + maturity: typeof raw.maturity === 'bigint' ? raw.maturity : BigInt(raw.maturity ?? 0), + penaltyBps: Number(raw.penalty_bps ?? raw.penaltyBps ?? 0), + complianceScore: Number(raw.compliance_score ?? raw.complianceScore ?? 100), + createdAt: + typeof raw.created_at === 'bigint' + ? raw.created_at + : typeof raw.createdAt === 'bigint' + ? raw.createdAt + : BigInt(raw.created_at ?? raw.createdAt ?? 0), + metadata: normalizeMetadata(raw.metadata), + }; +} + +export function normalizeAttestation(raw: any): OnChainAttestation { + return { + attestor: String(raw.attestor ?? ''), + complianceScore: Number(raw.compliance_score ?? raw.complianceScore ?? 0), + timestamp: typeof raw.timestamp === 'bigint' ? raw.timestamp : BigInt(raw.timestamp ?? 0), + }; +} + +export function normalizeDispute(raw: any): OnChainDispute | null { + if (!raw || (typeof raw === 'object' && Object.keys(raw).length === 0)) { + return null; + } + let code = 4; + let category = 'Other'; + const rawCat = raw.reason_category ?? raw.reasonCategory; + if (typeof rawCat === 'bigint' || typeof rawCat === 'number') { + code = Number(rawCat); + category = DISPUTE_REASON_NAMES[code] ?? `Category #${code}`; + } else if (typeof rawCat === 'string') { + category = rawCat; + } else if (Array.isArray(rawCat) && typeof rawCat[0] === 'string') { + category = rawCat[0]; + } + + return { + reasonCategory: category, + reasonCode: code, + reasonText: String(raw.reason_text ?? raw.reasonText ?? ''), + disputedAt: + typeof raw.disputed_at === 'bigint' + ? raw.disputed_at + : typeof raw.disputedAt === 'bigint' + ? raw.disputedAt + : BigInt(raw.disputed_at ?? raw.disputedAt ?? 0), + disputedBy: String(raw.disputed_by ?? raw.disputedBy ?? ''), + }; +} + +export function isNotFoundError(err: unknown): boolean { + if (!err) return false; + if (err instanceof TxError && err.code === EscrowErrorCode.NotFound) return true; + if (typeof err === 'object' && err !== null && 'code' in err && (err as any).code === EscrowErrorCode.NotFound) { + return true; + } + const msg = err instanceof Error ? err.message : String(err); + return /not\s*found|#3\b|contract.*?3\b/i.test(msg); +} + +function resolveServerAndContract( + optionsOrServer?: ReadOptions | rpc.Server | { simulateTransaction: (tx: any) => Promise }, + explicitContractId?: string, +): { server: rpc.Server | { simulateTransaction: (tx: any) => Promise }; contractId: string } { + let server: rpc.Server | { simulateTransaction: (tx: any) => Promise }; + let contractId = explicitContractId || ESCROW_CONTRACT_ID; + + if (optionsOrServer && 'simulateTransaction' in optionsOrServer) { + server = optionsOrServer; + } else if (optionsOrServer && typeof optionsOrServer === 'object') { + const opts = optionsOrServer as ReadOptions; + server = opts.server || new rpc.Server(SOROBAN_RPC_URL); + if (opts.contractId) contractId = opts.contractId; + } else { + server = new rpc.Server(SOROBAN_RPC_URL); + } + + if (!contractId && !explicitContractId) { + // If testing with a custom server without an explicit contract, supply dummy contract + if (optionsOrServer) { + contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + } + } + + return { server, contractId }; +} + +/** + * Simulates a read-only invocation on the escrow contract. + */ +export async function simulateEscrowRead( + functionName: string, + args: xdr.ScVal[], + optionsOrServer?: ReadOptions | rpc.Server | { simulateTransaction: (tx: any) => Promise }, + contractIdOverride?: string, +): Promise { + const { server, contractId } = resolveServerAndContract(optionsOrServer, contractIdOverride); + + if (!contractId) { + throw new Error('Escrow contract is not configured. Set NEXT_PUBLIC_ESCROW_CONTRACT_ID.'); + } + + const dummyAccount = new Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', '0'); + const contract = new Contract(contractId); + const tx = new TransactionBuilder(dummyAccount, { + fee: '100', + networkPassphrase: config.passphrase, + }) + .addOperation(contract.call(functionName, ...args)) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(tx); + + // Check for simulation errors + if (sim && typeof sim === 'object' && ('error' in sim || 'diagnostics' in sim)) { + if (sim.error || (Array.isArray(sim.diagnostics) && sim.diagnostics.length > 0 && !('result' in sim))) { + throw toTxError(sim); + } + } + + let retvalScVal: xdr.ScVal | undefined; + + if (sim && typeof sim === 'object') { + if ('result' in sim && sim.result && 'retval' in sim.result) { + retvalScVal = sim.result.retval as xdr.ScVal; + } else if ('results' in sim && Array.isArray(sim.results) && sim.results[0]?.xdr) { + retvalScVal = xdr.ScVal.fromXDR(sim.results[0].xdr, 'base64'); + } + } + + if (!retvalScVal) { + if (sim && typeof sim === 'object' && 'error' in sim) { + throw toTxError(sim); + } + return null; + } + + return scValToNative(retvalScVal); +} + +/** + * Reads a single commitment by u64 ID. Throws on NotFound or network error. + */ +export async function getCommitment( + id: string | bigint, + optionsOrServer?: ReadOptions | rpc.Server | { simulateTransaction: (tx: any) => Promise }, + contractId?: string, +): Promise { + const u64Val = BigInt(id); + const scVal = nativeToScVal(u64Val, { type: 'u64' }); + const raw = await simulateEscrowRead('get_commitment', [scVal], optionsOrServer, contractId); + if (!raw) { + const notFoundError = new TxError('contract', 'The requested commitment could not be found.', { + code: EscrowErrorCode.NotFound, + hint: 'Check the commitment ID and try again.', + }); + throw notFoundError; + } + return normalizeCommitment(raw); +} + +/** + * Reads historical compliance attestations for a commitment. + * Returns array sorted newest first. + */ +export async function getAttestations( + id: string | bigint, + optionsOrServer?: ReadOptions | rpc.Server | { simulateTransaction: (tx: any) => Promise }, + contractId?: string, +): Promise { + const u64Val = BigInt(id); + const scVal = nativeToScVal(u64Val, { type: 'u64' }); + const raw = await simulateEscrowRead('get_attestations', [scVal], optionsOrServer, contractId); + const list: OnChainAttestation[] = (Array.isArray(raw) ? raw : []).map(normalizeAttestation); + return list.sort((a, b) => (b.timestamp > a.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0)); +} + +/** + * Reads dispute status for a commitment. Returns null if not in dispute (None). + */ +export async function getDispute( + id: string | bigint, + optionsOrServer?: ReadOptions | rpc.Server | { simulateTransaction: (tx: any) => Promise }, + contractId?: string, +): Promise { + const u64Val = BigInt(id); + const scVal = nativeToScVal(u64Val, { type: 'u64' }); + const raw = await simulateEscrowRead('get_dispute', [scVal], optionsOrServer, contractId); + return normalizeDispute(raw); +} + +export function formatRelativeTime(timestampSeconds: bigint | number, nowMs = Date.now()): string { + const tsMs = + typeof timestampSeconds === 'bigint' + ? Number(timestampSeconds) * 1000 + : timestampSeconds * 1000; + const diffSec = Math.floor((nowMs - tsMs) / 1000); + if (diffSec < 0) return 'in the future'; + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + const diffMonths = Math.floor(diffDays / 30); + return `${diffMonths}mo ago`; +} + +export function formatAbsoluteTime(timestampSeconds: bigint | number): string { + if (!timestampSeconds || timestampSeconds === 0n || timestampSeconds === 0) { + return 'Not set'; + } + const date = new Date(Number(timestampSeconds) * 1000); + return date.toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; +} + +export function shortenAddress(addr: string): string { + if (!addr || addr.length <= 14) return addr; + return `${addr.slice(0, 6)}...${addr.slice(-6)}`; +} +