diff --git a/docs/rwa-asset-creation-wizard.md b/docs/rwa-asset-creation-wizard.md new file mode 100644 index 0000000..83ae82d --- /dev/null +++ b/docs/rwa-asset-creation-wizard.md @@ -0,0 +1,119 @@ +# RWA Asset Creation Wizard + +Closes #29. Documents the Issuer Console wizard for submitting a new RWA +asset issuance request: asset details entry, review, and submission into +the compliance-review queue. + +## Scope + +This document covers **creating a new issuance request**, not minting +supply. The two are deliberately distinct surfaces: + +| Concern | Document | +|---|---| +| Minting existing/approved supply to a recipient | [rwa-asset-minting-workflow.md](rwa-asset-minting-workflow.md) | +| Post-mint lifecycle states | [asset-lifecycle-status.md](asset-lifecycle-status.md) | +| Compliance-safe copy | [compliance-safe-wording.md](compliance-safe-wording.md) | +| Route access (`/issuer`) | [route-access.md](route-access.md) | + +A request created here starts in `pending` status in the existing Issuer +Console table (`draft → pending → approved → minted → rejected`, see +`src/fixtures/issuer.ts`). It only becomes eligible for the minting workflow +once a compliance reviewer moves it to `approved` — that review/approval +step is not implemented by this issue and is out of scope here. + +## Entry point + +- Route: `/issuer` (issuer/admin roles — see [route-access.md](route-access.md)) +- Page: `src/pages/issuer.tsx` — "New asset request" button opens the wizard + in a modal +- Component: `src/features/asset-creation/components/AssetCreationWizard.tsx` + +## Flow + +``` +form (asset name, ticker, asset class, initial supply, jurisdiction) + → validateAssetCreationRequest + → review (summary of entered details) + → submit → new IssuanceRequest{status: 'pending'} prepended to the table + → success screen (create another / done) +``` + +1. Issuer enters asset name, ticker, asset class, initial requested supply, + and jurisdiction. +2. On **Review request**, `validateAssetCreationRequest` runs + (`src/lib/assetCreationRequest.ts`), checking the ticker against every + ticker currently in the Issuer Console table. +3. Review screen shows a read-only summary of the entered values. +4. **Submit for review** re-validates (in case another request was created + in the meantime) and, if still valid, builds a new `IssuanceRequest` with + `status: 'pending'` and hands it to the parent page, which prepends it to + the table. +5. Success screen confirms submission and offers **Create another** (resets + the form) or **Done** (closes the modal). + +## Data model + +### Validation — `src/lib/assetCreationRequest.ts` + +Pure module (no React / SDK imports), mirroring `mintRequest.ts`'s pattern: + +- `AssetCreationInput` — assetName, ticker, amount (string), jurisdiction, + assetClass +- `AssetCreationContext` — optional `existingTickers`, optional soft + `maxAmount` +- `validateAssetCreationRequest()` — returns + `{ valid, error?, parsedAmount?, normalisedTicker? }` + +### Supported jurisdictions and asset classes + +`SUPPORTED_JURISDICTIONS` and `ASSET_CLASS_OPTIONS` are exported constants +in the same module, used to populate the wizard's select inputs and to +validate submissions. + +### Result — `src/fixtures/issuer.ts` (`IssuanceRequest`) + +The wizard's output is shaped directly as an `IssuanceRequest`, so it can be +added straight into the existing Issuer Console table with no adapter layer. + +## Edge cases + +| Case | Behaviour | +|---|---| +| Empty asset name / ticker / amount / jurisdiction / asset class | Blocked before review | +| Asset name under 3 characters | Blocked client-side | +| Malformed ticker (not 2-10 alphanumeric, optional single hyphen segment) | Blocked client-side | +| Ticker already used by an existing request | Blocked client-side (case-insensitive) | +| Zero / negative / non-numeric amount | Blocked client-side | +| Amount above soft cap (`DEFAULT_ASSET_CREATION_MAX_AMOUNT`) | Blocked client-side | +| Unsupported jurisdiction | Blocked client-side against `SUPPORTED_JURISDICTIONS` | +| Ticker becomes a duplicate between form and confirm (e.g. two tabs) | Re-validated on **Submit for review**, sent back to the form with an error | + +## Security & compliance assumptions + +- `SUPPORTED_JURISDICTIONS` is a **mock-mode UI allow-list**, not a + determination of real regulatory eligibility. It must not be presented as + legal or compliance advice — copy in the wizard follows + [compliance-safe-wording.md](compliance-safe-wording.md). +- Creating a request here does **not** mint any supply and does not touch + the SDK/provider layer at all; it only writes into local Issuer Console + state (mock-mode fixture data). A live backend would need a real + create-issuance-request API and persistence — this wizard's shape is + designed so that swap-in is additive (see + [mock-mode.md](mock-mode.md) for the project's general mock/live boundary + convention). +- Duplicate-ticker and amount-cap checks are UX guards only, not contract or + registry-level authorization. + +## Testing + +| Layer | Location | +|---|---| +| Pure validation | `src/lib/assetCreationRequest.test.ts` | +| Wizard (form validation, duplicate ticker, review, submit, reset, cancel) | `src/features/asset-creation/components/AssetCreationWizard.test.tsx` | + +## Related + +- Issue #29 — Add RWA asset creation wizard +- Issue #6 — RWA asset minting workflow (the counterpart this flow feeds into) +- `MintWorkflow.tsx` is the UX template this wizard mirrors \ No newline at end of file diff --git a/src/features/asset-creation/components/AssetCreationWizard.test.tsx b/src/features/asset-creation/components/AssetCreationWizard.test.tsx new file mode 100644 index 0000000..f8b8368 --- /dev/null +++ b/src/features/asset-creation/components/AssetCreationWizard.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AssetCreationWizard from './AssetCreationWizard'; + +function fillValidForm() { + fireEvent.change(screen.getByLabelText(/asset name/i), { + target: { value: 'Frankfurt Logistics Fund' }, + }); + fireEvent.change(screen.getByLabelText(/ticker/i), { target: { value: 'FR-LOG2' } }); + fireEvent.change(screen.getByLabelText(/initial requested supply/i), { + target: { value: '250000' }, + }); +} + +describe('AssetCreationWizard', () => { + it('renders the form step by default', () => { + render(); + expect(screen.getByText('New RWA asset request')).toBeInTheDocument(); + expect(screen.getByLabelText(/asset name/i)).toBeInTheDocument(); + }); + + it('blocks review with an inline error when fields are invalid', () => { + render(); + fireEvent.click(screen.getByText('Review request')); + expect(screen.getByRole('alert')).toHaveTextContent(/fill in every field/i); + expect(screen.queryByText('Review issuance request')).not.toBeInTheDocument(); + }); + + it('rejects a duplicate ticker before reaching review', () => { + render( + , + ); + fillValidForm(); + fireEvent.click(screen.getByText('Review request')); + expect(screen.getByRole('alert')).toHaveTextContent(/already exists/i); + }); + + it('advances to review with valid input, then submits and calls onCreate', () => { + const onCreate = vi.fn(); + render(); + + fillValidForm(); + fireEvent.click(screen.getByText('Review request')); + + expect(screen.getByText('Review issuance request')).toBeInTheDocument(); + expect(screen.getByText('FR-LOG2')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Submit for review')); + + expect(onCreate).toHaveBeenCalledTimes(1); + const created = onCreate.mock.calls[0][0]; + expect(created).toMatchObject({ + assetName: 'Frankfurt Logistics Fund', + ticker: 'FR-LOG2', + amount: 250000, + status: 'pending', + requestedBy: 'GALICE...TEST', + }); + + expect(screen.getByText('Request submitted')).toBeInTheDocument(); + }); + + it('lets the user go back from review to fix the form', () => { + render(); + fillValidForm(); + fireEvent.click(screen.getByText('Review request')); + fireEvent.click(screen.getByText('Back')); + expect(screen.getByText('New RWA asset request')).toBeInTheDocument(); + expect(screen.getByLabelText(/asset name/i)).toHaveValue('Frankfurt Logistics Fund'); + }); + + it('calls onCancel from the form step', () => { + const onCancel = vi.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('resets to a blank form after "Create another"', () => { + render(); + fillValidForm(); + fireEvent.click(screen.getByText('Review request')); + fireEvent.click(screen.getByText('Submit for review')); + fireEvent.click(screen.getByText('Create another')); + expect(screen.getByLabelText(/asset name/i)).toHaveValue(''); + }); +}); \ No newline at end of file diff --git a/src/features/asset-creation/components/AssetCreationWizard.tsx b/src/features/asset-creation/components/AssetCreationWizard.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/assetCreationRequest.test.ts b/src/lib/assetCreationRequest.test.ts new file mode 100644 index 0000000..76d124b --- /dev/null +++ b/src/lib/assetCreationRequest.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { + validateAssetCreationRequest, + DEFAULT_ASSET_CREATION_MAX_AMOUNT, + SUPPORTED_JURISDICTIONS, +} from './assetCreationRequest'; + +const baseInput = { + assetName: 'Manhattan Commercial Real Estate', + ticker: 'NY-CRE', + amount: '100000', + jurisdiction: 'US', + assetClass: 'Real Estate', +}; + +describe('validateAssetCreationRequest', () => { + it('accepts a well-formed asset creation request', () => { + const result = validateAssetCreationRequest(baseInput); + expect(result).toEqual({ + valid: true, + parsedAmount: 100000, + normalisedTicker: 'NY-CRE', + }); + }); + + it('normalises a lowercase ticker and jurisdiction', () => { + const result = validateAssetCreationRequest({ + ...baseInput, + ticker: 'ny-cre', + jurisdiction: 'us', + }); + expect(result.valid).toBe(true); + expect(result.normalisedTicker).toBe('NY-CRE'); + }); + + it('rejects missing fields', () => { + expect( + validateAssetCreationRequest({ + assetName: '', + ticker: '', + amount: '', + jurisdiction: '', + assetClass: '', + }), + ).toEqual({ valid: false, error: 'MISSING_FIELDS' }); + + expect( + validateAssetCreationRequest({ ...baseInput, assetClass: '' }).error, + ).toBe('MISSING_FIELDS'); + }); + + it('rejects an asset name that is too short', () => { + expect( + validateAssetCreationRequest({ ...baseInput, assetName: 'NY' }).error, + ).toBe('ASSET_NAME_TOO_SHORT'); + }); + + it('rejects a malformed ticker', () => { + expect(validateAssetCreationRequest({ ...baseInput, ticker: 'n' }).error).toBe( + 'INVALID_TICKER', + ); + expect( + validateAssetCreationRequest({ ...baseInput, ticker: 'TOO-LONG-SEGMENT-HERE' }).error, + ).toBe('INVALID_TICKER'); + expect( + validateAssetCreationRequest({ ...baseInput, ticker: 'NY_CRE' }).error, + ).toBe('INVALID_TICKER'); + }); + + it('rejects a ticker that already exists (case-insensitive)', () => { + const result = validateAssetCreationRequest( + { ...baseInput, ticker: 'ny-cre' }, + { existingTickers: ['NY-CRE', 'UST-6M'] }, + ); + expect(result).toEqual({ valid: false, error: 'DUPLICATE_TICKER' }); + }); + + it('rejects a non-positive amount', () => { + expect(validateAssetCreationRequest({ ...baseInput, amount: '0' }).error).toBe( + 'NON_POSITIVE_AMOUNT', + ); + expect(validateAssetCreationRequest({ ...baseInput, amount: '-5' }).error).toBe( + 'NON_POSITIVE_AMOUNT', + ); + expect(validateAssetCreationRequest({ ...baseInput, amount: 'abc' }).error).toBe( + 'NON_POSITIVE_AMOUNT', + ); + }); + + it('rejects an amount above the default max', () => { + const result = validateAssetCreationRequest({ + ...baseInput, + amount: String(DEFAULT_ASSET_CREATION_MAX_AMOUNT + 1), + }); + expect(result.error).toBe('AMOUNT_TOO_LARGE'); + }); + + it('respects a custom max amount from context', () => { + const result = validateAssetCreationRequest( + { ...baseInput, amount: '500' }, + { maxAmount: 100 }, + ); + expect(result.error).toBe('AMOUNT_TOO_LARGE'); + }); + + it('rejects an unsupported jurisdiction', () => { + expect( + validateAssetCreationRequest({ ...baseInput, jurisdiction: 'ZZ' }).error, + ).toBe('UNSUPPORTED_JURISDICTION'); + }); + + it('accepts every currently supported jurisdiction', () => { + for (const jurisdiction of SUPPORTED_JURISDICTIONS) { + expect( + validateAssetCreationRequest({ ...baseInput, jurisdiction }).valid, + ).toBe(true); + } + }); +}); \ No newline at end of file diff --git a/src/lib/assetCreationRequest.ts b/src/lib/assetCreationRequest.ts new file mode 100644 index 0000000..85fd31a --- /dev/null +++ b/src/lib/assetCreationRequest.ts @@ -0,0 +1,129 @@ +/** + * RWA Asset Creation Request — data model & validation. (Issue #29) + * + * Pure module with no React or SDK imports so it can be unit-tested in + * isolation and reused by the Issuer Console wizard or any future surface + * that submits a new asset issuance request for compliance review. + * + * Scope note: this validates and shapes a *request to create* a new RWA + * asset (an issuance request awaiting compliance review, landing in the + * Issuer Console table). It is distinct from minting existing supply to a + * recipient, which is handled by mintRequest.ts / MintWorkflow (Issue #6). + * A created asset only becomes mintable once approved through that + * separate, already-shipped review process. + */ + +export type AssetCreationErrorCode = + | 'MISSING_FIELDS' + | 'ASSET_NAME_TOO_SHORT' + | 'INVALID_TICKER' + | 'DUPLICATE_TICKER' + | 'NON_POSITIVE_AMOUNT' + | 'AMOUNT_TOO_LARGE' + | 'UNSUPPORTED_JURISDICTION'; + +export interface AssetCreationInput { + assetName: string; + ticker: string; + /** Initial requested supply, as typed in the form. */ + amount: string; + jurisdiction: string; + assetClass: string; +} + +export interface AssetCreationContext { + /** Tickers already registered elsewhere in the system (case-insensitive). */ + existingTickers?: string[]; + /** Soft cap for a single issuance request (UI guard only). */ + maxAmount?: number; +} + +export interface AssetCreationValidationResult { + valid: boolean; + error?: AssetCreationErrorCode; + /** Parsed amount, only present when valid. */ + parsedAmount?: number; + /** Normalised (uppercased, trimmed) ticker, only present when valid. */ + normalisedTicker?: string; +} + +/** + * Jurisdictions the mock compliance layer currently recognises. This is a + * UI-level allow-list only — it does not represent real regulatory scope + * and must not be presented to users as legal or compliance advice. See + * docs/rwa-asset-creation-wizard.md. + */ +export const SUPPORTED_JURISDICTIONS = ['US', 'EU', 'SG', 'JP', 'AE', 'GB', 'CH'] as const; + +export type SupportedJurisdiction = (typeof SUPPORTED_JURISDICTIONS)[number]; + +/** Asset classes offered in the creation wizard, aligned with existing catalogue entries. */ +export const ASSET_CLASS_OPTIONS = [ + 'Real Estate', + 'Fixed Income', + 'Private Equity', + 'Infrastructure', +] as const; + +/** + * Ticker shape: 2-10 uppercase letters/digits, optionally split by a single + * hyphen into two such segments (e.g. NY-CRE, UST-6M, SGPCN). + */ +const TICKER_PATTERN = /^[A-Z0-9]{2,10}(-[A-Z0-9]{2,10})?$/; + +/** Default soft cap for a single issuance request (UI guard only). */ +export const DEFAULT_ASSET_CREATION_MAX_AMOUNT = 1_000_000_000; + +export function validateAssetCreationRequest( + input: AssetCreationInput, + context: AssetCreationContext = {}, +): AssetCreationValidationResult { + const assetName = input.assetName.trim(); + const ticker = input.ticker.trim().toUpperCase(); + const amountStr = input.amount.trim(); + const jurisdiction = input.jurisdiction.trim().toUpperCase(); + const assetClass = input.assetClass.trim(); + + if (!assetName || !ticker || !amountStr || !jurisdiction || !assetClass) { + return { valid: false, error: 'MISSING_FIELDS' }; + } + + if (assetName.length < 3) { + return { valid: false, error: 'ASSET_NAME_TOO_SHORT' }; + } + + if (!TICKER_PATTERN.test(ticker)) { + return { valid: false, error: 'INVALID_TICKER' }; + } + + const existing = context.existingTickers?.map((t) => t.trim().toUpperCase()) ?? []; + if (existing.includes(ticker)) { + return { valid: false, error: 'DUPLICATE_TICKER' }; + } + + if (!SUPPORTED_JURISDICTIONS.includes(jurisdiction as SupportedJurisdiction)) { + return { valid: false, error: 'UNSUPPORTED_JURISDICTION' }; + } + + const parsedAmount = Number(amountStr); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + return { valid: false, error: 'NON_POSITIVE_AMOUNT' }; + } + + const maxAmount = context.maxAmount ?? DEFAULT_ASSET_CREATION_MAX_AMOUNT; + if (parsedAmount > maxAmount) { + return { valid: false, error: 'AMOUNT_TOO_LARGE' }; + } + + return { valid: true, parsedAmount, normalisedTicker: ticker }; +} + +export const ASSET_CREATION_ERROR_MESSAGES: Record = { + MISSING_FIELDS: 'Fill in every field before continuing.', + ASSET_NAME_TOO_SHORT: 'Asset name must be at least 3 characters.', + INVALID_TICKER: 'Ticker must be 2-10 letters/numbers, e.g. NY-CRE or UST-6M.', + DUPLICATE_TICKER: 'An asset with this ticker already exists.', + NON_POSITIVE_AMOUNT: 'Enter an initial requested supply greater than zero.', + AMOUNT_TOO_LARGE: 'Amount exceeds the maximum allowed for a single issuance request.', + UNSUPPORTED_JURISDICTION: 'This jurisdiction is not yet supported for issuance requests.', +}; \ No newline at end of file diff --git a/src/pages/issuer.tsx b/src/pages/issuer.tsx index 3578298..8fa5684 100644 --- a/src/pages/issuer.tsx +++ b/src/pages/issuer.tsx @@ -1,18 +1,72 @@ +import { useState } from 'react'; import Head from 'next/head'; +import { Plus, X } from 'lucide-react'; import RouteGuard from '@/components/RouteGuard'; import IssuanceRequestsTable from '@/features/issuer/components/IssuanceRequestsTable'; -import { mockIssuanceRequests } from '@/fixtures/issuer'; +import AssetCreationWizard from '@/features/asset-creation/components/AssetCreationWizard'; +import { mockIssuanceRequests, type IssuanceRequest } from '@/fixtures/issuer'; +import { useWallet } from '@/hooks/useWallet'; export default function IssuerPage() { + const { address } = useWallet(); + const [requests, setRequests] = useState(mockIssuanceRequests); + const [isWizardOpen, setIsWizardOpen] = useState(false); + + const existingTickers = requests.map((r) => r.ticker); + + const handleCreate = (request: IssuanceRequest) => { + setRequests((prev) => [request, ...prev]); + }; + return ( Issuer Console | Aegis RWA - Issuer Console - + + Issuer Console + setIsWizardOpen(true)} + className="inline-flex items-center gap-2 bg-aegis-dark hover:bg-slate-800 text-white px-4 py-2 rounded font-medium transition" + > + + New asset request + + + + + + {isWizardOpen && ( + { + if (e.target === e.currentTarget) setIsWizardOpen(false); + }} + > + + setIsWizardOpen(false)} + aria-label="Close" + className="absolute -top-2 -right-2 z-10 rounded-full bg-white p-1.5 shadow-sm border border-slate-200 text-slate-500 hover:text-slate-700" + > + + + setIsWizardOpen(false)} + /> + + + )} ); -} +} \ No newline at end of file