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
119 changes: 119 additions & 0 deletions docs/rwa-asset-creation-wizard.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 4 in src/features/asset-creation/components/AssetCreationWizard.test.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

File '/home/runner/work/aegis-dashboard/aegis-dashboard/src/features/asset-creation/components/AssetCreationWizard.tsx' is not a module.

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(<AssetCreationWizard onCreate={vi.fn()} />);
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(<AssetCreationWizard onCreate={vi.fn()} />);
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(
<AssetCreationWizard onCreate={vi.fn()} existingTickers={['FR-LOG2']} />,
);
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(<AssetCreationWizard onCreate={onCreate} requestedBy="GALICE...TEST" />);

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(<AssetCreationWizard onCreate={vi.fn()} />);
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(<AssetCreationWizard onCreate={vi.fn()} onCancel={onCancel} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onCancel).toHaveBeenCalledTimes(1);
});

it('resets to a blank form after "Create another"', () => {
render(<AssetCreationWizard onCreate={vi.fn()} />);
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('');
});
});
Empty file.
119 changes: 119 additions & 0 deletions src/lib/assetCreationRequest.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading
Loading