+ );
+ }
- {/* Inline whitelist confirmation — replaces the removed alert() */}
- {whitelistMessage && (
-
-
- Whitelist User
-
-
setState('review')}
- disabled={isLoading || !cleanAddress}
- className="flex-1 bg-aegis-dark hover:bg-slate-800 text-white py-2 rounded font-medium transition disabled:opacity-50"
- >
- Mint Asset
-
+ return (
+ <>
+
Admin Controls
+
+
+
+
+ Target Address
+
+ setAddress(e.target.value)}
+ />
+
+
+ {whitelistMessage && (
+
+
+ {whitelistMessage}
+ )}
+
+
+
+ Whitelist User
+
+ setState('review')}
+ disabled={isLoading || !cleanAddress}
+ className="flex-1 bg-aegis-dark hover:bg-slate-800 text-white py-2 rounded font-medium transition disabled:opacity-50"
+ >
+ Mint Asset
+
- >
- );
- };
+
+ >
+ );
+}
+
+export default function AdminPanel() {
+ const newMintFlow = useFeatureFlags((s) => s.flags.newMintFlow);
+
+ if (newMintFlow) {
+ return
;
+ }
return (
- {renderBody()}
+
);
}
diff --git a/src/features/minting/components/MintWorkflow.test.tsx b/src/features/minting/components/MintWorkflow.test.tsx
new file mode 100644
index 0000000..5be646f
--- /dev/null
+++ b/src/features/minting/components/MintWorkflow.test.tsx
@@ -0,0 +1,234 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { submissionLedger } from '@/features/forms/idempotency';
+import MintWorkflow from '@/features/minting/components/MintWorkflow';
+import type { RawTransactionOutcome } from '@/components/transactions/types';
+import { mintableAssetsFixture } from '@/features/minting/fixtures';
+
+/**
+ * Cover for the admin mint workflow (#6):
+ * validation, compliance pre-check, review → sign → receipt,
+ * idempotent confirm, and SDK error recovery for failure/unknown.
+ */
+
+const ADDRESS = 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ';
+const RECIPIENT = 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3';
+
+const mint = vi.fn<(...args: unknown[]) => Promise
>();
+const checkWhitelist = vi.fn(async () => true);
+const connect = vi.fn(async () => {});
+
+vi.mock('@/hooks/useAegis', () => ({
+ useAegis: () => ({
+ checkWhitelist: (...args: unknown[]) => checkWhitelist(...(args as [])),
+ mint: (...args: unknown[]) => mint(...args),
+ isLoading: false,
+ }),
+}));
+
+vi.mock('@/hooks/useWallet', () => ({
+ useWallet: () => ({ address: ADDRESS, network: 'TESTNET', connect }),
+}));
+
+/** Fills the form and advances to the review screen. */
+const reachReview = async (amount = '10') => {
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/recipient address/i), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText(/^amount/i), { target: { value: amount } });
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ return screen.findByRole('button', { name: 'Confirm & Sign' });
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ submissionLedger.clear();
+ checkWhitelist.mockResolvedValue(true);
+ mint.mockResolvedValue({ status: 'SUCCESS', hash: 'mock_tx_hash_mint_0987654321' });
+});
+
+describe('MintWorkflow — happy path', () => {
+ it('checks compliance then shows review before signing', async () => {
+ await reachReview('100');
+
+ expect(checkWhitelist).toHaveBeenCalledWith(RECIPIENT);
+ expect(screen.getByRole('button', { name: 'Confirm & Sign' })).toBeInTheDocument();
+ expect(screen.getByText(/issues new supply/i)).toBeInTheDocument();
+ expect(mint).not.toHaveBeenCalled();
+ });
+
+ it('calls mint with recipient, amount, and asset ticker on confirm', async () => {
+ fireEvent.click(await reachReview('25'));
+
+ await screen.findByText('Transaction confirmed');
+ expect(mint).toHaveBeenCalledWith(
+ RECIPIENT,
+ 25,
+ expect.any(Function),
+ 'NY-CRE',
+ );
+ });
+
+ it('shows a receipt after a successful mint', async () => {
+ fireEvent.click(await reachReview());
+
+ expect(await screen.findByText('Transaction confirmed')).toBeInTheDocument();
+ expect(screen.getByText(/Mint · Success/i)).toBeInTheDocument();
+ });
+});
+
+describe('MintWorkflow — validation', () => {
+ it('blocks empty fields before any network call', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(/fill all fields/i);
+ expect(checkWhitelist).not.toHaveBeenCalled();
+ expect(mint).not.toHaveBeenCalled();
+ });
+
+ it('blocks an invalid recipient address', async () => {
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/recipient address/i), {
+ target: { value: 'not-valid' },
+ });
+ fireEvent.change(screen.getByLabelText(/^amount/i), { target: { value: '10' } });
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(/valid Stellar address/i);
+ expect(checkWhitelist).not.toHaveBeenCalled();
+ });
+
+ it('blocks a non-positive amount', async () => {
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/recipient address/i), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText(/^amount/i), { target: { value: '0' } });
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(/greater than zero/i);
+ expect(checkWhitelist).not.toHaveBeenCalled();
+ });
+});
+
+describe('MintWorkflow — compliance pre-check', () => {
+ it('surfaces a not-whitelisted recipient before review', async () => {
+ checkWhitelist.mockResolvedValue(false);
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/recipient address/i), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText(/^amount/i), { target: { value: '10' } });
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(/not KYC whitelisted/i);
+ expect(screen.queryByRole('button', { name: 'Confirm & Sign' })).not.toBeInTheDocument();
+ expect(mint).not.toHaveBeenCalled();
+ });
+
+ it('surfaces an RPC failure distinctly from not-whitelisted', async () => {
+ checkWhitelist.mockRejectedValue(new Error('RPC down'));
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/recipient address/i), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText(/^amount/i), { target: { value: '10' } });
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(/could not verify compliance/i);
+ expect(mint).not.toHaveBeenCalled();
+ });
+});
+
+describe('MintWorkflow — idempotency guard', () => {
+ it('submits once when Confirm is clicked twice in a row', async () => {
+ const confirm = await reachReview();
+
+ fireEvent.click(confirm);
+ fireEvent.click(confirm);
+
+ await screen.findByText('Transaction confirmed');
+ expect(mint).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('MintWorkflow — error and unknown states', () => {
+ it('offers recovery for a compliance refusal', async () => {
+ mint.mockResolvedValue({
+ status: 'FAILED',
+ errorMessage: 'Recipient account is not authorised to hold this asset.',
+ });
+
+ fireEvent.click(await reachReview());
+
+ expect(await screen.findByText('Blocked by compliance rules')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Edit details' })).toBeInTheDocument();
+ });
+
+ it('warns instead of retrying when the outcome is unconfirmed', async () => {
+ mint.mockResolvedValue({ status: 'not_a_real_status', hash: 'mock_tx_hash_mint_0987654321' });
+
+ fireEvent.click(await reachReview());
+
+ expect(await screen.findByText('Outcome could not be confirmed')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /check the explorer/i })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /try again|retry/i })).not.toBeInTheDocument();
+ });
+
+ it('surfaces a thrown transport error with a recovery plan', async () => {
+ mint.mockRejectedValue(new TypeError('Failed to fetch'));
+
+ fireEvent.click(await reachReview());
+
+ expect(await screen.findByText('Network unreachable')).toBeInTheDocument();
+ expect(screen.getByText(/may have reached the network/i)).toBeInTheDocument();
+ });
+
+ it('lets the user return to the form after a recoverable failure', async () => {
+ mint.mockResolvedValueOnce({
+ status: 'FAILED',
+ errorMessage: 'Invalid destination address.',
+ });
+
+ fireEvent.click(await reachReview());
+ await screen.findByText('The request was rejected as invalid');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
+ expect(await screen.findByRole('button', { name: /review mint/i })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Confirm & Sign' }));
+
+ await screen.findByText('Transaction confirmed');
+ expect(mint).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('MintWorkflow — asset selector', () => {
+ it('includes selected asset details on the review screen', async () => {
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/^asset$/i), {
+ target: { value: 'ust-6m' },
+ });
+ fireEvent.change(screen.getByLabelText(/recipient address/i), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText(/^amount/i), { target: { value: '50' } });
+ fireEvent.click(screen.getByRole('button', { name: /review mint/i }));
+
+ await screen.findByRole('button', { name: 'Confirm & Sign' });
+ expect(screen.getByRole('heading', { name: /Mint UST-6M/i })).toBeInTheDocument();
+ expect(screen.getByText('Fixed Income')).toBeInTheDocument();
+ expect(screen.getByText('US Treasury Bill 6-Mo (UST-6M)')).toBeInTheDocument();
+ });
+});
diff --git a/src/features/minting/components/MintWorkflow.tsx b/src/features/minting/components/MintWorkflow.tsx
new file mode 100644
index 0000000..f28129b
--- /dev/null
+++ b/src/features/minting/components/MintWorkflow.tsx
@@ -0,0 +1,347 @@
+import { useState } from 'react';
+import { useAegis } from '@/hooks/useAegis';
+import { useWallet } from '@/hooks/useWallet';
+import { formatAmount, truncateAddress } from '@/utils/formatting';
+import TransactionReview from '@/components/transactions/TransactionReview';
+import TransactionProgress from '@/components/transactions/TransactionProgress';
+import TransactionReceipt from '@/components/transactions/TransactionReceipt';
+import { mapToTransactionResult } from '@/components/transactions/statusMapper';
+import { getExplorerUrl } from '@/components/transactions/explorerLink';
+import type {
+ RawTransactionOutcome,
+ TransactionDetails,
+ TransactionResult,
+ TransactionState,
+} from '@/components/transactions/types';
+import { useIdempotentSubmit } from '@/features/forms/idempotency';
+import { validateMintRequest, MINT_ERROR_MESSAGES } from '@/lib/mintRequest';
+import {
+ buildRecoveryPlan,
+ classifySdkError,
+ SdkErrorRecovery,
+ type ClassifiedSdkError,
+ type RecoveryPlan,
+} from '@/features/sdk-recovery';
+import {
+ findMintableAsset,
+ mintableAssetsFixture,
+ type MintableAsset,
+} from '@/features/minting/fixtures';
+
+interface MintWorkflowProps {
+ /** Optional override for the mintable asset catalogue (tests). */
+ assets?: MintableAsset[];
+ /** Called when the user finishes a successful mint and chooses to start over. */
+ onComplete?: () => void;
+}
+
+/**
+ * Guided admin minting workflow: select asset → enter recipient & amount →
+ * compliance pre-check → review → Freighter sign (via provider phases) →
+ * receipt / recovery. Closes #6.
+ */
+export default function MintWorkflow({
+ assets = mintableAssetsFixture,
+ onComplete,
+}: MintWorkflowProps) {
+ const { checkWhitelist, mint, isLoading } = useAegis();
+ const { address, network, connect } = useWallet();
+
+ const [assetId, setAssetId] = useState(assets[0]?.id ?? '');
+ const [recipient, setRecipient] = useState('');
+ const [amount, setAmount] = useState('');
+ const [error, setError] = useState('');
+ const [state, setState] = useState('idle');
+ const [result, setResult] = useState(null);
+ const [failure, setFailure] = useState<{
+ error: ClassifiedSdkError;
+ plan: RecoveryPlan;
+ } | null>(null);
+
+ const selectedAsset = findMintableAsset(assetId) ?? assets.find((a) => a.id === assetId);
+ const cleanRecipient = recipient.trim();
+ const numericAmount = parseFloat(amount);
+
+ // One key per (signer, asset, recipient, amount, network). Double-click on
+ // Confirm or a recovery retry resolves to the same key and cannot produce a
+ // second mint; editing any field produces a new key. See docs/form-idempotency.md.
+ const submission = useIdempotentSubmit({
+ scope: 'mint',
+ actor: address,
+ payload: {
+ assetId,
+ recipient: cleanRecipient,
+ amount: Number.isFinite(numericAmount) ? numericAmount : null,
+ network: network ?? null,
+ },
+ });
+
+ const details: TransactionDetails = {
+ action: 'mint',
+ title: selectedAsset ? `Mint ${selectedAsset.ticker}` : 'Mint asset',
+ description: 'This issues new supply directly to the recipient address.',
+ network: network ?? undefined,
+ rows: [
+ ...(selectedAsset
+ ? [
+ { label: 'Asset', value: `${selectedAsset.name} (${selectedAsset.ticker})` },
+ { label: 'Asset class', value: selectedAsset.assetClass },
+ ]
+ : []),
+ {
+ label: 'Amount',
+ value: selectedAsset
+ ? `${formatAmount(parseFloat(amount) || 0)} ${selectedAsset.ticker}`
+ : formatAmount(parseFloat(amount) || 0),
+ },
+ { label: 'Recipient', value: cleanRecipient, mono: true },
+ ...(address
+ ? [{ label: 'Signer', value: truncateAddress(address), mono: true }]
+ : []),
+ { label: 'Network', value: network ?? 'Unknown' },
+ ],
+ };
+
+ const handleReview = async () => {
+ setError('');
+
+ const validation = validateMintRequest(
+ { recipient, amount, assetId },
+ { maxDecimals: selectedAsset?.decimals },
+ );
+
+ if (!validation.valid || validation.parsedAmount === undefined) {
+ return setError(MINT_ERROR_MESSAGES[validation.error!]);
+ }
+
+ // Compliance pre-check before the review screen. A thrown error means the
+ // check itself failed (e.g. RPC unreachable) — distinct from resolving
+ // false, which means the recipient was checked and is not whitelisted.
+ let isCompliant: boolean;
+ try {
+ isCompliant = await checkWhitelist(cleanRecipient);
+ } catch {
+ return setError('Could not verify compliance status. Please try again.');
+ }
+ if (!isCompliant) {
+ return setError('Recipient is not KYC whitelisted.');
+ }
+
+ setState('review');
+ };
+
+ const handleConfirm = async () => {
+ setFailure(null);
+ setState('signing');
+
+ const outcome = await submission.submit((idempotencyKey) => {
+ // The mock client has no idempotency parameter yet. When the real SDK
+ // accepts one, pass this key straight through.
+ void idempotencyKey;
+ return mint(cleanRecipient, numericAmount, setState, selectedAsset?.ticker);
+ });
+
+ if (outcome.status === 'blocked') {
+ const replayed = outcome.verdict.entry?.result;
+ if (outcome.verdict.decision === 'replay_result' && replayed) {
+ setResult(mapToTransactionResult(replayed));
+ return;
+ }
+
+ setState('pending');
+ setError(outcome.verdict.message ?? '');
+ return;
+ }
+
+ if (outcome.status === 'failed') {
+ showRecovery(outcome.error);
+ return;
+ }
+
+ const mapped = mapToTransactionResult(outcome.result);
+ if (mapped.status === 'failure' || mapped.status === 'unknown') {
+ showRecovery(outcome.result);
+ return;
+ }
+
+ setResult(mapped);
+ };
+
+ const showRecovery = (failed: unknown) => {
+ const classified = classifySdkError(failed, { walletConnected: Boolean(address) });
+ const plan = buildRecoveryPlan(classified);
+
+ setFailure({ error: classified, plan });
+ setState('idle');
+
+ if (!plan.reuseIdempotencyKey) submission.reset();
+ };
+
+ const handleEditDetails = () => {
+ setFailure(null);
+ setError('');
+ setState('idle');
+ };
+
+ const handleReset = () => {
+ setResult(null);
+ setFailure(null);
+ setError('');
+ setState('idle');
+ setRecipient('');
+ setAmount('');
+ onComplete?.();
+ };
+
+ const renderBody = () => {
+ if (failure) {
+ return (
+ <>
+
+ {selectedAsset ? `Mint ${selectedAsset.ticker}` : 'Mint asset'}
+
+ {
+ void connect();
+ handleEditDetails();
+ },
+ dismiss: handleReset,
+ }}
+ />
+ >
+ );
+ }
+
+ if (result) {
+ return (
+
+ );
+ }
+
+ if (state === 'signing' || state === 'pending') {
+ return ;
+ }
+
+ if (state === 'review') {
+ return (
+ setState('idle')}
+ isSubmitting={submission.isSubmitting}
+ />
+ );
+ }
+
+ return (
+ <>
+ Mint RWA asset
+
+ Select an asset, enter the recipient and amount, then review before signing with Freighter.
+ Recipient compliance is checked before the review screen.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Asset
+
+
setAssetId(e.target.value)}
+ >
+ {assets.map((asset) => (
+
+ {asset.ticker} — {asset.name}
+
+ ))}
+
+ {selectedAsset && (
+
+ {selectedAsset.assetClass} · up to {selectedAsset.decimals} decimal places
+
+ )}
+
+
+
+
+ Recipient address
+
+ setRecipient(e.target.value)}
+ autoComplete="off"
+ spellCheck={false}
+ />
+
+
+
+
+ Amount
+ {selectedAsset ? (
+ ({selectedAsset.ticker})
+ ) : null}
+
+
setAmount(e.target.value)}
+ min="0"
+ step="any"
+ />
+
+ In mock mode, amounts 0.01 / 0.02 / 0.03 simulate failure, pending, and unknown outcomes.
+
+
+
+
+
+ {isLoading ? 'Checking compliance…' : 'Review mint'}
+
+ >
+ );
+ };
+
+ return (
+
+ {renderBody()}
+
+ );
+}
diff --git a/src/features/minting/fixtures.ts b/src/features/minting/fixtures.ts
new file mode 100644
index 0000000..67b26fa
--- /dev/null
+++ b/src/features/minting/fixtures.ts
@@ -0,0 +1,48 @@
+/**
+ * Mintable RWA asset fixtures for the admin minting workflow.
+ *
+ * These describe assets an admin may select when issuing new supply.
+ * They are synthetic and for UI / mock-mode use only — not live on-chain
+ * registry data.
+ */
+
+export interface MintableAsset {
+ id: string;
+ name: string;
+ ticker: string;
+ decimals: number;
+ assetClass: string;
+ /** Short label shown in the asset selector. */
+ description: string;
+}
+
+export const mintableAssetsFixture: MintableAsset[] = [
+ {
+ id: 'ny-cre',
+ name: 'Manhattan Commercial Real Estate',
+ ticker: 'NY-CRE',
+ decimals: 2,
+ assetClass: 'Real Estate',
+ description: 'Fractionalized Manhattan commercial property.',
+ },
+ {
+ id: 'ust-6m',
+ name: 'US Treasury Bill 6-Mo',
+ ticker: 'UST-6M',
+ decimals: 2,
+ assetClass: 'Fixed Income',
+ description: 'Tokenized 6-month US Treasury Bill position.',
+ },
+ {
+ id: 'eu-infra',
+ name: 'EU Infrastructure Bond',
+ ticker: 'EU-INFRA',
+ decimals: 7,
+ assetClass: 'Fixed Income',
+ description: 'Tokenized European infrastructure debt instrument.',
+ },
+];
+
+export function findMintableAsset(assetId: string): MintableAsset | undefined {
+ return mintableAssetsFixture.find((asset) => asset.id === assetId);
+}
diff --git a/src/fixtures/diagnostics.ts b/src/fixtures/diagnostics.ts
index 670b051..5ecdefa 100644
--- a/src/fixtures/diagnostics.ts
+++ b/src/fixtures/diagnostics.ts
@@ -20,7 +20,7 @@ export const mockDiagnosticsFixture = {
wallet: 'GCFXMOCKWALLET0000000000000000000000000000000000000000',
network: 'LOCAL_MOCK',
flags: {
- newMintFlow: false,
+ newMintFlow: true,
complianceBanner: true,
darkMode: false,
mockMode: true,
diff --git a/src/hooks/useAegis.ts b/src/hooks/useAegis.ts
index 6a61ed3..4413594 100644
--- a/src/hooks/useAegis.ts
+++ b/src/hooks/useAegis.ts
@@ -105,6 +105,7 @@ export const useAegis = () => {
to: string,
amount: number,
onPhase?: PhaseListener,
+ assetTicker?: string,
): Promise => {
setIsLoading(true);
try {
@@ -119,6 +120,8 @@ export const useAegis = () => {
recipient: to,
createdAt: new Date().toISOString(),
action: 'mint',
+ amount,
+ assetTicker,
notes: 'Admin mint action from dashboard',
});
diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts
index 19012d1..873ec3a 100644
--- a/src/hooks/useFeatureFlags.ts
+++ b/src/hooks/useFeatureFlags.ts
@@ -28,7 +28,7 @@ export interface FeatureFlagMeta {
export const FLAG_METADATA: Record = {
newMintFlow: {
label: 'New Mint Flow',
- description: 'Enables the redesigned admin mint experience.',
+ description: 'Enables the redesigned admin mint experience (asset selector, compliance pre-check, review, receipt). Default on.',
},
complianceBanner: {
label: 'Compliance Banner',
@@ -51,7 +51,9 @@ export const FLAG_METADATA: Record = {
};
const DEFAULT_FLAGS: Record = {
- newMintFlow: false,
+ // Issue #6 — guided RWA mint workflow is the default admin mint experience.
+ // Toggle off in the feature-flags panel to fall back to the legacy fixed-amount panel.
+ newMintFlow: true,
complianceBanner: true,
darkMode: false,
mockMode: isMockModeEnabled(),
diff --git a/src/lib/__fixtures__/diagnostics.ts b/src/lib/__fixtures__/diagnostics.ts
index 7e0f31b..7327f82 100644
--- a/src/lib/__fixtures__/diagnostics.ts
+++ b/src/lib/__fixtures__/diagnostics.ts
@@ -6,7 +6,7 @@ export const healthyDiagnostics = {
wallet: 'GBXY...WXYZ',
network: 'TESTNET',
flags: {
- newMintFlow: false,
+ newMintFlow: true,
complianceBanner: true,
darkMode: false
}
@@ -20,7 +20,7 @@ export const brokenDiagnostics = {
wallet: 'Not connected',
network: 'Not connected',
flags: {
- newMintFlow: false,
+ newMintFlow: true,
complianceBanner: true,
darkMode: false
}
diff --git a/src/lib/mintRequest.test.ts b/src/lib/mintRequest.test.ts
new file mode 100644
index 0000000..16aac2f
--- /dev/null
+++ b/src/lib/mintRequest.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from 'vitest';
+import {
+ validateMintRequest,
+ DEFAULT_MINT_MAX_AMOUNT,
+ isPlausibleStellarAddress,
+} from './mintRequest';
+
+const recipientAddress = 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3';
+
+const baseInput = {
+ recipient: recipientAddress,
+ amount: '1000',
+ assetId: 'ny-cre',
+};
+
+const context = { maxDecimals: 2 };
+
+describe('isPlausibleStellarAddress (mintRequest re-export)', () => {
+ it('accepts a well-formed address', () => {
+ expect(isPlausibleStellarAddress(recipientAddress)).toBe(true);
+ });
+});
+
+describe('validateMintRequest', () => {
+ it('accepts a well-formed mint request', () => {
+ const result = validateMintRequest(baseInput, context);
+ expect(result).toEqual({ valid: true, parsedAmount: 1000 });
+ });
+
+ it('rejects missing fields', () => {
+ expect(validateMintRequest({ recipient: '', amount: '', assetId: '' }, context)).toEqual({
+ valid: false,
+ error: 'MISSING_FIELDS',
+ });
+ expect(
+ validateMintRequest({ ...baseInput, assetId: '' }, context).error,
+ ).toBe('MISSING_FIELDS');
+ });
+
+ it('rejects an invalid address', () => {
+ const result = validateMintRequest(
+ { ...baseInput, recipient: 'not-an-address' },
+ context,
+ );
+ expect(result.error).toBe('INVALID_ADDRESS');
+ });
+
+ it('trims whitespace around the recipient and amount', () => {
+ const result = validateMintRequest(
+ {
+ recipient: ` ${recipientAddress} `,
+ amount: ' 50.5 ',
+ assetId: 'ny-cre',
+ },
+ context,
+ );
+ expect(result).toEqual({ valid: true, parsedAmount: 50.5 });
+ });
+
+ it('rejects zero and negative amounts', () => {
+ expect(validateMintRequest({ ...baseInput, amount: '0' }, context).error).toBe(
+ 'NON_POSITIVE_AMOUNT',
+ );
+ expect(validateMintRequest({ ...baseInput, amount: '-5' }, context).error).toBe(
+ 'NON_POSITIVE_AMOUNT',
+ );
+ });
+
+ it('rejects decimal precision beyond the asset max', () => {
+ const result = validateMintRequest(
+ { ...baseInput, amount: '1.123' },
+ context,
+ );
+ expect(result.error).toBe('PRECISION_OVERFLOW');
+ });
+
+ it('defaults to 7 max decimals when the asset does not specify one', () => {
+ const result = validateMintRequest(
+ { ...baseInput, amount: '1.1234567' },
+ {},
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ it('rejects amounts above the soft cap', () => {
+ const result = validateMintRequest(
+ { ...baseInput, amount: String(DEFAULT_MINT_MAX_AMOUNT + 1) },
+ context,
+ );
+ expect(result.error).toBe('AMOUNT_TOO_LARGE');
+ });
+
+ it('respects a custom maxAmount', () => {
+ const result = validateMintRequest(baseInput, { ...context, maxAmount: 500 });
+ expect(result.error).toBe('AMOUNT_TOO_LARGE');
+ });
+});
diff --git a/src/lib/mintRequest.ts b/src/lib/mintRequest.ts
new file mode 100644
index 0000000..aca234c
--- /dev/null
+++ b/src/lib/mintRequest.ts
@@ -0,0 +1,88 @@
+/**
+ * Admin RWA Mint Request — data model & validation. (Issue #6)
+ *
+ * Pure module with no React or SDK imports so it can be unit-tested in
+ * isolation and reused by any surface that validates a mint before the
+ * compliance pre-check, review screen, or SDK call.
+ */
+
+import { isPlausibleStellarAddress } from '@/lib/transferRequest';
+
+export type MintValidationErrorCode =
+ | 'MISSING_FIELDS'
+ | 'INVALID_ADDRESS'
+ | 'NON_POSITIVE_AMOUNT'
+ | 'PRECISION_OVERFLOW'
+ | 'AMOUNT_TOO_LARGE';
+
+export interface MintRequestInput {
+ recipient: string;
+ amount: string;
+ assetId: string;
+}
+
+export interface MintRequestContext {
+ /** Max decimal places the selected asset supports. */
+ maxDecimals?: number;
+ /**
+ * Optional soft cap for a single mint. Protocol-level supply limits remain
+ * authoritative on-chain; this only blocks obviously oversized form input.
+ */
+ maxAmount?: number;
+}
+
+export interface MintValidationResult {
+ valid: boolean;
+ error?: MintValidationErrorCode;
+ /** Parsed amount, only present when valid. */
+ parsedAmount?: number;
+}
+
+/** Default soft cap for a single admin mint (UI guard only). */
+export const DEFAULT_MINT_MAX_AMOUNT = 1_000_000_000;
+
+export function validateMintRequest(
+ input: MintRequestInput,
+ context: MintRequestContext = {},
+): MintValidationResult {
+ const recipient = input.recipient.trim();
+ const amountStr = input.amount.trim();
+ const assetId = input.assetId.trim();
+
+ if (!recipient || !amountStr || !assetId) {
+ return { valid: false, error: 'MISSING_FIELDS' };
+ }
+
+ if (!isPlausibleStellarAddress(recipient)) {
+ return { valid: false, error: 'INVALID_ADDRESS' };
+ }
+
+ const parsedAmount = Number(amountStr);
+
+ if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
+ return { valid: false, error: 'NON_POSITIVE_AMOUNT' };
+ }
+
+ const maxDecimals = context.maxDecimals ?? 7;
+ const decimalPart = amountStr.split('.')[1];
+ if (decimalPart && decimalPart.length > maxDecimals) {
+ return { valid: false, error: 'PRECISION_OVERFLOW' };
+ }
+
+ const maxAmount = context.maxAmount ?? DEFAULT_MINT_MAX_AMOUNT;
+ if (parsedAmount > maxAmount) {
+ return { valid: false, error: 'AMOUNT_TOO_LARGE' };
+ }
+
+ return { valid: true, parsedAmount };
+}
+
+export const MINT_ERROR_MESSAGES: Record = {
+ MISSING_FIELDS: 'Select an asset and fill all fields.',
+ INVALID_ADDRESS: 'Recipient does not look like a valid Stellar address.',
+ NON_POSITIVE_AMOUNT: 'Enter a valid amount greater than zero.',
+ PRECISION_OVERFLOW: 'Too many decimal places for this asset.',
+ AMOUNT_TOO_LARGE: 'Amount exceeds the maximum allowed for a single mint.',
+};
+
+export { isPlausibleStellarAddress };