diff --git a/README.md b/README.md index 531c1c4..0123ab3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Key resources for contributors: - [Frontend Developer Guide](docs/frontend-guide.md) — Styling conventions and page creation - [Compliance Reviewer Workflow](docs/compliance-reviewer-workflow.md) — Guide for compliance operators reviewing investor eligibility - [Investor Transfer Request Flow](docs/investor-transfer-request-flow.md) — Request-validation edge cases (address, self-transfer, amount, precision) and RPC-failure handling for the transfer modal +- [RWA Asset Minting Workflow](docs/rwa-asset-minting-workflow.md) — Admin mint: asset selector, compliance pre-check, review, Freighter signing, receipt (Issue #6) - [Compliance-Safe Wording Guidance](docs/compliance-safe-wording.md) — Canonical disclaimer text, typed helper, and reviewer checklist for compliance-facing copy - [RWA Asset Lifecycle Status](docs/asset-lifecycle-status.md) — Lifecycle state machine, transition validation, and status UI for already-minted RWA assets - [Bulk Compliance Review](docs/bulk-compliance-review.md) — Bulk compliance review table with action confirmation modal diff --git a/docs/README.md b/docs/README.md index cb52817..533ee13 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Reference material for contributors implementing new functionality. | [investor-dashboard.md](investor-dashboard.md) | Portfolio page data flow, mock portfolio shape, SDK assumptions | | [investor-transfer-eligibility.md](investor-transfer-eligibility.md) | Eligibility checks before transfer submission | | [investor-transfer-request-flow.md](investor-transfer-request-flow.md) | Request-validation layer: address/amount edge cases, RPC-failure vs. not-whitelisted (Issue #41) | +| [rwa-asset-minting-workflow.md](rwa-asset-minting-workflow.md) | Admin RWA mint workflow: asset selector, compliance pre-check, review, Freighter signing, receipt (Issue #6) | | [asset-lifecycle-status.md](asset-lifecycle-status.md) | RWA asset lifecycle state machine, transition validation, badge/timeline UI (Issue #30) | | [investor-onboarding-eligibility.md](investor-onboarding-eligibility.md) | Investor onboarding eligibility page, evaluation precedence, SDK mapping (Issue #28) | | [admin-role-management-design.md](admin-role-management-design.md) | Admin role resolution, whitelist heuristic, mock admin address | diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5016d9e..fb1c5ee 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -26,7 +26,7 @@ When opening a GitHub Issue or requesting support in Discord, click **Copy Repor "wallet": "GBXY...WXYZ", "network": "TESTNET", "flags": { - "newMintFlow": false, + "newMintFlow": true, "complianceBanner": true, "darkMode": false } diff --git a/docs/feature-flags.md b/docs/feature-flags.md index b8b5408..62c1274 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -7,9 +7,13 @@ Flags are managed by a [zustand](https://github.com/pmndrs/zustand) store at `sr import { useFeatureFlags } from '@/hooks/useFeatureFlags'; const isNewMintFlowEnabled = useFeatureFlags((s) => s.flags.newMintFlow); - ``` +`newMintFlow` defaults to **true** and gates the guided RWA mint workflow on +`/admin` (Issue #6). Toggle it off in the feature-flags panel to fall back to +the legacy fixed-amount mint panel. See +[rwa-asset-minting-workflow.md](rwa-asset-minting-workflow.md). + Adding a new flag 1. Add the key to the `FeatureFlagKey` union in `useFeatureFlags.ts`. diff --git a/docs/mock-mode.md b/docs/mock-mode.md index da6ca0f..4d38417 100644 --- a/docs/mock-mode.md +++ b/docs/mock-mode.md @@ -107,9 +107,11 @@ The `MockAegisProvider` maps the transfer/mint `amount` to a specific outcome: | `0.03` | Unknown status (exercises the fallback UI path) | | Any other value | `SUCCESS` | -This lets you exercise every receipt state without touching any code. Open the -Admin page, enter any G-address longer than 50 characters, and mint with one of -these amounts. +This lets you exercise every receipt state without touching any code. On the +Admin page with the guided mint workflow (`newMintFlow`, default on), select an +asset, enter any G-address longer than 50 characters, and mint with one of +these amounts (0.01 / 0.02 / 0.03). See +[rwa-asset-minting-workflow.md](rwa-asset-minting-workflow.md). --- diff --git a/docs/rwa-asset-minting-workflow.md b/docs/rwa-asset-minting-workflow.md new file mode 100644 index 0000000..27dd8a6 --- /dev/null +++ b/docs/rwa-asset-minting-workflow.md @@ -0,0 +1,124 @@ +# Admin RWA Asset Minting Workflow + +Closes #6. Documents the guided admin minting flow for compliant RWA asset +issuance: asset selection, recipient & amount validation, compliance +pre-check, review, Freighter signing phases, and receipt / recovery states. + +## Scope + +This document covers the **minting workflow** itself. Related concerns are +documented elsewhere and not duplicated here: + +| Concern | Document | +|---|---| +| Shared review / progress / receipt UI | [transaction-components.md](transaction-components.md) | +| Double-submit / idempotency | [form-idempotency.md](form-idempotency.md) | +| Failure / unknown recovery | [sdk-error-recovery.md](sdk-error-recovery.md) | +| Feature flag gating | [feature-flags.md](feature-flags.md) | +| Mock amount → outcome mapping | [mock-mode.md](mock-mode.md) | +| Post-mint lifecycle states | [asset-lifecycle-status.md](asset-lifecycle-status.md) | + +## Entry point + +- Route: `/admin` (admin role only — see [route-access.md](route-access.md)) +- Component: `src/features/minting/components/MintWorkflow.tsx` +- Wired from `src/features/admin/components/AdminPanel.tsx` when the + `newMintFlow` feature flag is enabled (default: **on**) +- Legacy fixed-amount panel remains available when `newMintFlow` is toggled off + +## Flow + +``` +idle (asset + recipient + amount) + → validateMintRequest + → checkWhitelist (compliance pre-check) + → review (TransactionReview) + → signing / pending (TransactionProgress, Freighter via provider phases) + → success receipt | failure/unknown recovery (SdkErrorRecovery) +``` + +1. Admin selects a mintable asset from the catalogue + (`src/features/minting/fixtures.ts`). +2. Admin enters recipient address and amount. +3. On **Review mint**, `validateMintRequest` runs + (`src/lib/mintRequest.ts`). +4. If valid, `useAegis().checkWhitelist(recipient)` runs. RPC failure and + "not whitelisted" are surfaced as distinct errors — neither advances to + review. +5. Review screen shows asset, amount, recipient, signer, and network. +6. **Confirm & Sign** submits through `useIdempotentSubmit({ scope: 'mint' })` + and `useAegis().mint(...)`. Provider phase callbacks drive the progress UI + (`signing` → `pending`); Freighter signing is owned by the provider / + wallet layer, not called directly from the dashboard. +7. Success / pending → `TransactionReceipt`. Failure / unknown → + `SdkErrorRecovery` with a classified plan. + +## Data model + +### Validation — `src/lib/mintRequest.ts` + +Pure module (no React / SDK imports): + +- `MintRequestInput` — recipient, amount (string), assetId +- `MintRequestContext` — `maxDecimals`, optional soft `maxAmount` +- `validateMintRequest()` — returns `{ valid, error?, parsedAmount? }` + +Reuses `isPlausibleStellarAddress` from `transferRequest.ts` (shape check +only — not full StrKey/CRC16 validation). + +### Mintable assets — `src/features/minting/fixtures.ts` + +Synthetic catalogue for the selector (`MintableAsset`: id, name, ticker, +decimals, assetClass, description). Replace with an SDK registry read when +the live asset-registry API is available. + +## Edge cases + +| Case | Behaviour | +|---|---| +| Empty asset / recipient / amount | Blocked before any network call | +| Malformed Stellar address | Blocked client-side (shape check) | +| Zero / negative amount | Blocked client-side | +| Decimal precision beyond asset decimals | Blocked client-side | +| Amount above soft cap | Blocked client-side (`DEFAULT_MINT_MAX_AMOUNT`) | +| Whitelist RPC failure vs not whitelisted | Distinct error messages | +| Double-submit on Confirm | Idempotency guard — single provider call | +| Provider FAILED / unknown / thrown error | Recovery panel; unknown does not offer blind retry | + +## Mock mode outcomes + +Because amount is user-entered (unlike the legacy fixed `1000`), admins can +exercise non-success paths in mock mode: + +| Amount | Mock outcome | +|---|---| +| `0.01` | FAILED | +| `0.02` | PENDING | +| `0.03` | unknown status | +| anything else | SUCCESS | + +## Security & compliance assumptions + +- The whitelist check is **protocol-level compliance enforcement**, not legal + or financial advice. UI copy must stay consistent with + [compliance-safe-wording.md](compliance-safe-wording.md) and + [sdk-error-recovery.md](sdk-error-recovery.md). +- On-chain / RPC whitelist status is authoritative; the client only decides + whether to *attempt* the mint. +- Soft amount caps and address shape checks are UX guards only — the SDK / + contracts remain the source of truth. + +## Testing + +| Layer | Location | +|---|---| +| Pure validation | `src/lib/mintRequest.test.ts` | +| Workflow (happy, validation, compliance, idempotency, recovery) | `src/features/minting/components/MintWorkflow.test.tsx` | +| Flag wiring | `src/features/admin/components/AdminPanel.test.tsx` | +| Provider mint outcomes | `src/__tests__/sdk/provider.test.ts` | + +## Related + +- Issue #6 — Implement RWA asset minting workflow +- Transfer counterpart: [investor-transfer-request-flow.md](investor-transfer-request-flow.md) +- `TransferModal` is the UX template this flow mirrors diff --git a/docs/test-first-contribution-guide.md b/docs/test-first-contribution-guide.md index b0e60e6..f95e468 100644 --- a/docs/test-first-contribution-guide.md +++ b/docs/test-first-contribution-guide.md @@ -25,7 +25,7 @@ Each area below links to an existing test file that shows the pattern to follow. | **Investor views** | `src/features/investor/InvestorEligibilityPanel.test.tsx` | Render the view under loading, empty, error, and populated states. Assert on screen text, badge labels, and presence/absence of elements. | | **Compliance screens** | `src/features/admin/components/ComplianceInfo.test.tsx` | Test each verdict (pass, fail, pending, unknown) — never default an unknown verdict to a safe badge. | | **Asset registration** | `src/lib/eligibility.test.ts` | Test card rendering with valid, malformed, and missing metadata. Assert the UI shows an error state rather than rendering blank or incorrect values. | -| **Minting** | `src/features/investor/components/TransferModal.test.tsx` | Test input validation before submit. Cover invalid address, empty input, provider rejection, and double-submit. | +| **Minting** | `src/features/minting/components/MintWorkflow.test.tsx` | Test input validation before submit. Cover invalid address, empty input, not-whitelisted, provider rejection, and double-submit. | | **Wallet connection** | `src/lib/route-guard.test.ts` (mock wallet pattern) | Test `connect`, `disconnect`, and `tryAutoReconnect` state transitions. Cover Freighter-not-installed, rejected prompt, and network mismatch. | | **Diagnostics** | `src/lib/diagnostics/redact.test.ts` | Test redaction rules, env-var fallbacks, and malformed input. Assert the report renders without throwing and secrets remain redacted. | diff --git a/docs/testing-standard.md b/docs/testing-standard.md index aaca38a..119d44f 100644 --- a/docs/testing-standard.md +++ b/docs/testing-standard.md @@ -45,7 +45,7 @@ And one document that goes deeper on SDK-adjacent logic specifically: | **Investor views** | `src/features/investor/components/PortfolioList.tsx`, `PortfolioEmptyState.tsx`, `PortfolioErrorState.tsx`, `PortfolioDisclaimer.tsx` | Required — list rendering logic for a given portfolio shape | Required — the view responds correctly to loading/empty/error/populated states from the hook it consumes | Required — empty portfolio, disconnected wallet, load error (see [InvestorEligibilityPanel.test.tsx](../src/features/investor/InvestorEligibilityPanel.test.tsx) for the pattern) | Required — one screenshot per state (empty, error, populated) | | **Compliance screens** | `src/features/admin/components/ComplianceInfo.tsx`, `BulkComplianceReview.tsx`, `src/features/assets/components/ComplianceBadge.tsx` | Required — status-to-copy/badge mapping | Required if the screen consumes a compliance verdict from a hook or fixture | Required — rejected, review-flagged, and unknown/pending verdicts (see [ComplianceInfo.test.tsx](../src/features/admin/components/ComplianceInfo.test.tsx)) | Required for any change to how a verdict is displayed | | **Asset registration** | `src/features/assets/components/AssetCard.tsx`, `AssetCardSkeleton.tsx`, `TransferEligibilityBadge.tsx`, RWA metadata parsing (see [SDK standard](sdk-testing-standard.md)) | Required — card rendering for a given asset shape | Required if the card consumes a live/mocked provider read | Required — missing/malformed metadata, zero balance, ineligible transfer | Required for any visual change to the card or badges | -| **Minting** | `AdminPanel.tsx` mint flow; provider call itself is covered by the [SDK standard](sdk-testing-standard.md#minimum-coverage-by-change-type) | Required — input validation before submit | Required — provider called with correct arguments, phase callbacks fire in order | Required — invalid address, empty input, provider rejection, double-submit | Required — before/after for disabled and loading states | +| **Minting** | `src/features/minting/components/MintWorkflow.tsx`; provider call itself is covered by the [SDK standard](sdk-testing-standard.md#minimum-coverage-by-change-type) | Required — input validation before submit (`mintRequest.ts`) | Required — provider called with correct arguments, phase callbacks fire in order; compliance checked before review | Required — invalid address, empty input, not-whitelisted, provider rejection, double-submit | Required — before/after for disabled and loading states | | **Wallet connection** | `src/hooks/useWallet.ts` | Required — `connect`, `disconnect`, and `tryAutoReconnect` state transitions | Not required unless a component's rendering depends on a specific transition | Required — Freighter not installed, `requestAccess` rejected/throws, `tryAutoReconnect` with no prior grant, network mismatch | Required if the connect/disconnect UI changes — capture the connected, disconnected, and error-banner states | | **Diagnostics** | `src/features/diagnostics/components/DiagnosticsPanel.tsx`, `StatusCard.tsx`, `src/lib/diagnostics/redact.ts` | Required — `redact.ts` already has coverage ([redact.test.ts](../src/lib/diagnostics/redact.test.ts)); new redaction rules need a case added there | Required if `DiagnosticsPanel` changes what it reads from the wallet/feature-flag stores | Required — missing env var, wallet disconnected, malformed contract ID | Required for any layout change — the "Copy Report" output must still be redacted correctly, screenshot the copied JSON | diff --git a/docs/transaction-components.md b/docs/transaction-components.md index 8e83a1c..5a646ae 100644 --- a/docs/transaction-components.md +++ b/docs/transaction-components.md @@ -177,9 +177,14 @@ status as legal or financial advice. - **`src/features/investor/components/TransferModal.tsx`** — the KYC whitelist check runs first, then `Review Transfer` opens `TransactionReview`, confirming signs and submits the transfer, and the receipt replaces the old `alert("Transfer Successful!")`. -- **`src/components/AdminPanel.tsx`** — `Mint Asset` opens the review inline in - the card, then progress, then the receipt. - -`Whitelist User` in the admin panel still uses a plain `alert()`: it does not go +- **`src/features/minting/components/MintWorkflow.tsx`** — guided admin mint (Issue #6): + asset selector, amount/recipient validation, compliance pre-check, then + `TransactionReview` → progress → receipt / SDK recovery. Wired from + `AdminPanel` when `newMintFlow` is enabled (default on). See + [rwa-asset-minting-workflow.md](rwa-asset-minting-workflow.md). +- **`src/features/admin/components/AdminPanel.tsx`** — hosts the mint workflow + (or the legacy fixed-amount panel when `newMintFlow` is off). + +`Whitelist User` in the legacy admin panel still uses an inline confirmation: it does not go through `useAegis` and has no contract call or hash behind it yet. It should move onto these components (`action: 'compliance-update'`) as soon as it does. diff --git a/src/__tests__/hooks/useFeatureFlags.test.ts b/src/__tests__/hooks/useFeatureFlags.test.ts index fff2adc..438cc2e 100644 --- a/src/__tests__/hooks/useFeatureFlags.test.ts +++ b/src/__tests__/hooks/useFeatureFlags.test.ts @@ -7,15 +7,15 @@ beforeEach(() => { describe('useFeatureFlags', () => { it('has the expected default values', () => { const { flags } = useFeatureFlags.getState(); - expect(flags.newMintFlow).toBe(false); + expect(flags.newMintFlow).toBe(true); expect(flags.complianceBanner).toBe(true); expect(flags.darkMode).toBe(false); }); - it('toggles a flag from false to true', () => { + it('toggles a flag from true to false', () => { const { toggleFlag } = useFeatureFlags.getState(); toggleFlag('newMintFlow'); - expect(useFeatureFlags.getState().flags.newMintFlow).toBe(true); + expect(useFeatureFlags.getState().flags.newMintFlow).toBe(false); }); it('toggles a flag back to its original value', () => { @@ -63,16 +63,16 @@ describe('useFeatureFlags', () => { resetFlags(); const { flags } = useFeatureFlags.getState(); - expect(flags.newMintFlow).toBe(false); + expect(flags.newMintFlow).toBe(true); expect(flags.darkMode).toBe(false); }); it('isEnabled reflects current flag state', () => { const { setFlag, isEnabled } = useFeatureFlags.getState(); - expect(isEnabled('newMintFlow')).toBe(false); + expect(isEnabled('newMintFlow')).toBe(true); - setFlag('newMintFlow', true); - expect(useFeatureFlags.getState().isEnabled('newMintFlow')).toBe(true); + setFlag('newMintFlow', false); + expect(useFeatureFlags.getState().isEnabled('newMintFlow')).toBe(false); }); // --- mockMode flag --- diff --git a/src/features/admin/components/AdminPanel.test.tsx b/src/features/admin/components/AdminPanel.test.tsx new file mode 100644 index 0000000..93372c4 --- /dev/null +++ b/src/features/admin/components/AdminPanel.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AdminPanel from '@/features/admin/components/AdminPanel'; +import { useFeatureFlags } from '@/hooks/useFeatureFlags'; + +vi.mock('@/hooks/useAegis', () => ({ + useAegis: () => ({ + checkWhitelist: vi.fn(async () => true), + mint: vi.fn(), + isLoading: false, + }), +})); + +vi.mock('@/hooks/useWallet', () => ({ + useWallet: () => ({ + address: 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ', + network: 'TESTNET', + connect: vi.fn(), + }), +})); + +beforeEach(() => { + useFeatureFlags.getState().resetFlags(); +}); + +describe('AdminPanel — mint flow flag', () => { + it('renders the guided MintWorkflow when newMintFlow is enabled', () => { + useFeatureFlags.getState().setFlag('newMintFlow', true); + render(); + + expect(screen.getByRole('heading', { name: /mint rwa asset/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/^asset$/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /review mint/i })).toBeInTheDocument(); + }); + + it('renders the legacy fixed-amount panel when newMintFlow is disabled', () => { + useFeatureFlags.getState().setFlag('newMintFlow', false); + render(); + + expect(screen.getByRole('heading', { name: /admin controls/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /mint asset/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /whitelist user/i })).toBeInTheDocument(); + }); +}); diff --git a/src/features/admin/components/AdminPanel.tsx b/src/features/admin/components/AdminPanel.tsx index b419616..a9306ac 100644 --- a/src/features/admin/components/AdminPanel.tsx +++ b/src/features/admin/components/AdminPanel.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useAegis } from '@/hooks/useAegis'; import { useWallet } from '@/hooks/useWallet'; +import { useFeatureFlags } from '@/hooks/useFeatureFlags'; import { formatAmount } from '@/utils/formatting'; import TransactionReview from '@/components/transactions/TransactionReview'; import TransactionProgress from '@/components/transactions/TransactionProgress'; @@ -13,10 +14,15 @@ import type { TransactionResult, TransactionState, } from '@/components/transactions/types'; +import MintWorkflow from '@/features/minting/components/MintWorkflow'; const MINT_AMOUNT = 1000; -export default function AdminPanel() { +/** + * Legacy fixed-amount mint path kept behind `newMintFlow=false` for rollback. + * Prefer MintWorkflow (issue #6) when the flag is enabled. + */ +function LegacyMintPanel() { const { mint, isLoading } = useAegis(); const { network } = useWallet(); const [address, setAddress] = useState(''); @@ -24,7 +30,6 @@ export default function AdminPanel() { const [result, setResult] = useState(null); const [whitelistMessage, setWhitelistMessage] = useState(null); - // Pasted Stellar addresses often carry surrounding whitespace. const cleanAddress = address.trim(); const details: TransactionDetails = { @@ -41,7 +46,6 @@ export default function AdminPanel() { const handleWhitelist = async () => { // TODO: replace with a real contract.whitelist(address) call once the SDK is live. - // For now, show an inline confirmation instead of a blocking alert(). setWhitelistMessage(`Address ${cleanAddress} has been submitted for whitelisting.`); setTimeout(() => setWhitelistMessage(null), 4000); }; @@ -63,86 +67,93 @@ export default function AdminPanel() { setWhitelistMessage(null); }; - const renderBody = () => { - if (result) { - return ( - - ); - } - - if (state === 'signing' || state === 'pending') { - return ; - } + if (result) { + return ( + + ); + } - if (state === 'review') { - return ( - - ); - } + if (state === 'signing' || state === 'pending') { + return ; + } + if (state === 'review') { return ( - <> -

Admin Controls

- -
-
- - setAddress(e.target.value)} - /> -
+ + ); + } - {/* Inline whitelist confirmation — replaces the removed alert() */} - {whitelistMessage && ( -
-
- )} - -
- - + return ( + <> +

Admin Controls

+ +
+
+ + setAddress(e.target.value)} + /> +
+ + {whitelistMessage && ( +
+
+ )} + +
+ +
- - ); - }; +
+ + ); +} + +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} +
+ )} + +
+
+ + + {selectedAsset && ( +

+ {selectedAsset.assetClass} · up to {selectedAsset.decimals} decimal places +

+ )} +
+ +
+ + setRecipient(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ +
+ + setAmount(e.target.value)} + min="0" + step="any" + /> +

+ In mock mode, amounts 0.01 / 0.02 / 0.03 simulate failure, pending, and unknown outcomes. +

+
+
+ + + + ); + }; + + 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 };