diff --git a/README.md b/README.md
index 047f44d..28b333b 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ Key resources for contributors:
- [Bulk Compliance Review](docs/bulk-compliance-review.md) — Bulk compliance review table with action confirmation modal
- [Compliance Status Panel](docs/compliance-status-panel.md) — Address-level compliance status for investor and admin views (Issue #175)
- [Transaction Review Modal](docs/transaction-review-modal.md) — Pre-signature review modal, operation summary mapper, and risk notes (Issue #177)
+- [Admin Action Receipts](docs/admin-action-receipts.md) — Privileged action status, target, hash, explorer link, and next-step guidance (Issue #179)
- [Environment Mismatch Blocking Screen](docs/environment-mismatch-blocking.md) — Full-page blocking screen when the wallet network does not match the dashboard target network
- [Investor Onboarding Eligibility](docs/investor-onboarding-eligibility.md) — Investor onboarding eligibility page, evaluation precedence, and SDK mapping
- [Performance Budget Review](docs/performance-budget-review.md) — Typed budget threshold evaluation, edge cases, and reviewer checklist
diff --git a/docs/README.md b/docs/README.md
index 92da8a9..84dc499 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -32,6 +32,7 @@ Reference material for contributors implementing new functionality.
|---|---|
| [transaction-components.md](transaction-components.md) | Transfer modal, history table, and operation-type mapping |
| [transaction-review-modal.md](transaction-review-modal.md) | Pre-signature review modal, operation summary mapper, risk notes (Issue #177) |
+| [admin-action-receipts.md](admin-action-receipts.md) | Admin operation receipts, explorer links, next actions, limitations (Issue #179) |
| [transaction-history.md](transaction-history.md) | Normalised transaction model, supported operation types, fixture coverage |
| [table-filtering.md](table-filtering.md) | Reusable table filtering, sorting, search, and saved-views pattern |
| [empty-state-components.md](empty-state-components.md) | Reusable `EmptyState` component — variants, props, and usage examples |
diff --git a/docs/admin-action-receipts.md b/docs/admin-action-receipts.md
new file mode 100644
index 0000000..7a184f4
--- /dev/null
+++ b/docs/admin-action-receipts.md
@@ -0,0 +1,92 @@
+# Admin Action Receipts
+
+Issue #179 adds a consistent receipt view for privileged dashboard actions.
+After an action resolves, admins see its status, operation, target, transaction
+hash, explorer link (when available), and a context-specific next action.
+
+## Architecture
+
+The feature lives in `src/features/admin/receipts/`:
+
+| File | Purpose |
+| --- | --- |
+| `types.ts` | Typed admin operations and normalized receipt model |
+| `mapAdminActionReceipt.ts` | Maps provider/local outcomes into receipt data |
+| `AdminActionReceiptView.tsx` | Admin view composed from shared `TransactionReceipt` |
+| `fixtures.ts` | Major operations and success/failure/pending/unknown states |
+
+The mapper reuses:
+
+- `mapToTransactionResult` for SDK/RPC status normalization
+- `getExplorerUrl` for trusted Stellar Expert links
+- `TransactionReceipt` for consistent status, hash, and detail rendering
+
+## Represented operations
+
+| Operation | Current integration | Chain evidence |
+| --- | --- | --- |
+| Whitelist add / revoke | `WhitelistActionModal` | Provider hash and explorer link when returned |
+| Mint | `MintWorkflow` and legacy admin mint | Provider hash and explorer link when returned |
+| Asset registration | `AssetCreationWizard` | Local issuance request only; no hash |
+| Role change | Typed fixture / expected view | No dashboard SDK action is wired yet |
+| Bulk compliance update | `ComplianceUpdateModal` | Local success today; explorer link only when a future provider returns a hash |
+
+`AdminActionOperation` intentionally distinguishes whitelist add from revoke even
+though both use the shared `whitelist` transaction presentation label.
+
+## Receipt states
+
+The view supports the shared transaction statuses:
+
+- `success` — action confirmed or local request accepted
+- `failure` — provider rejected the action or returned an error
+- `pending` — submitted but not confirmed
+- `unknown` — the outcome cannot be confirmed
+
+Pending and unknown receipts tell the admin to verify network state before
+retrying. This reduces duplicate privileged actions when the original submission
+may still complete.
+
+## Explorer links
+
+Explorer links are shown only when both are true:
+
+1. The provider returned a transaction hash.
+2. The wallet network maps to a supported Stellar Expert network (`TESTNET` or
+ public/mainnet aliases).
+
+A hash on an unsupported network remains visible, but the link is omitted and a
+limitation note explains why. Missing hashes are never fabricated.
+
+## Next actions
+
+Each operation maps to a useful follow-up:
+
+- Whitelist add/revoke → **Back to whitelist**
+- Mint → **Mint another**
+- Asset registration → **Create another**
+- Role change → **Review role assignments**
+
+Failure uses **Review action**. Pending/unknown uses **Check transaction status**.
+The caller owns navigation/reset behavior; the mapper owns labels and guidance.
+
+## Limitations
+
+- Asset creation currently creates a local `IssuanceRequest` and does not submit
+ an on-chain registration transaction. Its receipt therefore has no hash or
+ explorer link.
+- Role-change provider/UI support does not exist yet. Fixtures document the
+ expected receipt contract so future SDK integration does not require a new
+ view.
+- Mock provider hashes are synthetic and intended only for dashboard testing.
+- An absent hash does not prove an action failed. Admins should check the list or
+ transaction history before retrying.
+
+## Tests and fixtures
+
+- `mapAdminActionReceipt.test.ts` covers status mapping, explorer behavior,
+ limitations, major operations, and receipt states.
+- `AdminActionReceiptView.test.tsx` covers status/operation/target/hash display,
+ explorer links, next actions, and local-action limitations.
+- Existing whitelist, mint, admin, and asset-creation flow tests exercise the
+ integrated view.
diff --git a/docs/architecture.md b/docs/architecture.md
index 3254cca..8749f03 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -30,4 +30,8 @@ The UI is strictly separated into pages and domain-specific features:
- `src/components/transactions/` provides the shared review-before-sign UI:
- `TransactionReview` / `TransactionReviewModal`
- `operationSummary` mapper for transfer, mint, whitelist, and compliance updates
- - progress / receipt / status mapping used by all sensitive signing flows
\ No newline at end of file
+ - progress / receipt / status mapping used by all sensitive signing flows
+- `src/features/admin/receipts/` encapsulates:
+ - admin operation receipt types for whitelist, mint, asset registration, and role changes
+ - SDK/local-outcome mapping onto shared transaction status and explorer helpers
+ - next-action guidance and fixtures for all receipt states
\ No newline at end of file
diff --git a/docs/transaction-components.md b/docs/transaction-components.md
index baec96d..bf292ac 100644
--- a/docs/transaction-components.md
+++ b/docs/transaction-components.md
@@ -94,6 +94,11 @@ const outcome = await transfer(recipient, amount, setState);
details={details} // same object used for the review
onClose={reset}
explorerUrl={getExplorerUrl(result.txHash, network)}
+ nextAction={{ // optional — used by admin receipts
+ label: 'Mint another',
+ description: 'Review the confirmed amount before starting another mint.',
+ onClick: reset,
+ }}
/>
```
@@ -108,6 +113,9 @@ Handles all four outcomes with its own icon, colour and badge:
The transaction hash row and the explorer link are only rendered when available,
so a failure that never reached the network shows neither.
+`nextAction` and `limitation` are optional. The admin receipt feature uses them
+for operation-specific follow-up guidance and to explain missing chain evidence.
+See [admin-action-receipts.md](admin-action-receipts.md).
## Status mapping
diff --git a/src/components/transactions/TransactionFixtureGallery.test.tsx b/src/components/transactions/TransactionFixtureGallery.test.tsx
index 8b4e632..a5abe52 100644
--- a/src/components/transactions/TransactionFixtureGallery.test.tsx
+++ b/src/components/transactions/TransactionFixtureGallery.test.tsx
@@ -14,6 +14,8 @@ describe('TransactionFixtureGallery', () => {
// expect(screen.getByText(/submitting to the network/i)).toBeInTheDocument();
expect(screen.getAllByText(/submitting to the network/i),).toHaveLength(2);
expect(screen.getByText(/transaction confirmed/i)).toBeInTheDocument();
+ expect(screen.getByText(/pending receipt/i)).toBeInTheDocument();
+ expect(screen.getByText(/transaction submitted/i)).toBeInTheDocument();
expect(screen.getByText(/transaction failed/i)).toBeInTheDocument();
expect(screen.getByText(/transaction status unknown/i)).toBeInTheDocument();
});
diff --git a/src/components/transactions/TransactionReceipt.tsx b/src/components/transactions/TransactionReceipt.tsx
index 8db978e..fbe2e40 100644
--- a/src/components/transactions/TransactionReceipt.tsx
+++ b/src/components/transactions/TransactionReceipt.tsx
@@ -14,6 +14,12 @@ import {
type TransactionStatus,
} from './types';
+export interface TransactionReceiptAction {
+ label: string;
+ onClick: () => void;
+ description?: string;
+}
+
interface TransactionReceiptProps {
result: TransactionResult;
details: TransactionDetails;
@@ -23,6 +29,10 @@ interface TransactionReceiptProps {
* `getExplorerUrl` — `null` simply hides the link.
*/
explorerUrl?: string | null;
+ /** Optional operation-specific action shown above the generic close button. */
+ nextAction?: TransactionReceiptAction;
+ /** Explains why a hash or explorer link may not be available. */
+ limitation?: string;
}
const STATUS_STYLES: Record<
@@ -65,6 +75,8 @@ export default function TransactionReceipt({
details,
onClose,
explorerUrl,
+ nextAction,
+ limitation,
}: TransactionReceiptProps) {
const { Icon, iconClass, badgeClass, label } = STATUS_STYLES[result.status];
@@ -122,10 +134,37 @@ export default function TransactionReceipt({
)}
+ {limitation && (
+
+ {limitation}
+
+ )}
+
+ {nextAction && (
+
+ {nextAction.description && (
+
+ {nextAction.description}
+
+ )}
+
+
+ )}
+
diff --git a/src/components/transactions/explorerLink.test.ts b/src/components/transactions/explorerLink.test.ts
new file mode 100644
index 0000000..ea2e203
--- /dev/null
+++ b/src/components/transactions/explorerLink.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { getExplorerUrl } from './explorerLink';
+
+describe('getExplorerUrl', () => {
+ it('builds stellar.expert links for supported networks', () => {
+ expect(getExplorerUrl('abc123', 'TESTNET')).toBe(
+ 'https://stellar.expert/explorer/testnet/tx/abc123',
+ );
+ expect(getExplorerUrl('abc123', 'PUBLIC')).toBe(
+ 'https://stellar.expert/explorer/public/tx/abc123',
+ );
+ expect(getExplorerUrl('abc123', 'MAINNET')).toBe(
+ 'https://stellar.expert/explorer/public/tx/abc123',
+ );
+ });
+
+ it('returns null when the hash or network is missing/unsupported', () => {
+ expect(getExplorerUrl(undefined, 'TESTNET')).toBeNull();
+ expect(getExplorerUrl('abc123', null)).toBeNull();
+ expect(getExplorerUrl('abc123', 'FUTURENET')).toBeNull();
+ expect(getExplorerUrl(' ', 'TESTNET')).toBeNull();
+ });
+});
diff --git a/src/components/transactions/fixtures.ts b/src/components/transactions/fixtures.ts
index b9f2956..cfa5df6 100644
--- a/src/components/transactions/fixtures.ts
+++ b/src/components/transactions/fixtures.ts
@@ -152,6 +152,15 @@ export const transactionFixtureGalleryEntries: TransactionFixtureGalleryEntry[]
result: successResultFixture,
explorerUrl: 'https://stellar.expert/explorer/testnet/tx/b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e',
},
+ {
+ id: 'receipt-pending',
+ kind: 'receipt',
+ title: 'Pending receipt',
+ description: 'Preview the submitted-but-unconfirmed receipt state.',
+ details: transferDetailsFixture,
+ result: pendingResultFixture,
+ explorerUrl: 'https://stellar.expert/explorer/testnet/tx/c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f50',
+ },
{
id: 'receipt-failure',
kind: 'receipt',
diff --git a/src/components/transactions/statusMapper.test.ts b/src/components/transactions/statusMapper.test.ts
new file mode 100644
index 0000000..bd12f4e
--- /dev/null
+++ b/src/components/transactions/statusMapper.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import { mapToTransactionResult } from './statusMapper';
+
+describe('mapToTransactionResult', () => {
+ it('maps successful RPC outcomes with a transaction hash', () => {
+ expect(
+ mapToTransactionResult({
+ status: 'SUCCESS',
+ hash: 'abc123',
+ }),
+ ).toMatchObject({
+ status: 'success',
+ txHash: 'abc123',
+ message: 'Transaction confirmed',
+ });
+ });
+
+ it('maps pending and unknown statuses', () => {
+ expect(mapToTransactionResult({ status: 'PENDING', txHash: 'pending-hash' })).toMatchObject({
+ status: 'pending',
+ txHash: 'pending-hash',
+ message: 'Transaction submitted',
+ });
+
+ expect(mapToTransactionResult({ status: 'not_a_real_status', hash: 'x' })).toMatchObject({
+ status: 'unknown',
+ txHash: 'x',
+ message: 'Transaction status unknown',
+ });
+ });
+
+ it('treats thrown errors and error fields as failures', () => {
+ expect(mapToTransactionResult(new Error('Wallet rejected'))).toMatchObject({
+ status: 'failure',
+ detail: 'Wallet rejected',
+ });
+
+ expect(
+ mapToTransactionResult({
+ status: 'SUCCESS',
+ errorMessage: 'Recipient account is not authorised to hold this asset.',
+ }),
+ ).toMatchObject({
+ status: 'failure',
+ detail: 'Recipient account is not authorised to hold this asset.',
+ });
+ });
+
+ it('maps bare status strings and nullish outcomes', () => {
+ expect(mapToTransactionResult('CONFIRMED')).toMatchObject({ status: 'success' });
+ expect(mapToTransactionResult(null)).toMatchObject({ status: 'unknown' });
+ });
+});
diff --git a/src/components/transactions/types.ts b/src/components/transactions/types.ts
index 73d930f..d2d369e 100644
--- a/src/components/transactions/types.ts
+++ b/src/components/transactions/types.ts
@@ -35,7 +35,9 @@ export type TransactionAction =
| 'transfer'
| 'mint'
| 'whitelist'
- | 'compliance-update';
+ | 'compliance-update'
+ | 'asset-registration'
+ | 'role-change';
/** Human-readable label for each action, shared by review and receipt. */
export const TRANSACTION_ACTION_LABELS: Record = {
@@ -43,6 +45,8 @@ export const TRANSACTION_ACTION_LABELS: Record = {
mint: 'Mint',
whitelist: 'Whitelist',
'compliance-update': 'Compliance update',
+ 'asset-registration': 'Asset registration',
+ 'role-change': 'Role change',
};
/** A single label/value line in the review and receipt summaries. */
diff --git a/src/features/admin/components/AdminPanel.tsx b/src/features/admin/components/AdminPanel.tsx
index 3877a71..99f64de 100644
--- a/src/features/admin/components/AdminPanel.tsx
+++ b/src/features/admin/components/AdminPanel.tsx
@@ -4,10 +4,12 @@ import { useWallet } from '@/hooks/useWallet';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
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 { buildMintSummary } from '@/components/transactions/operationSummary';
+import {
+ AdminActionReceiptView,
+ mapAdminActionReceipt,
+} from '@/features/admin/receipts';
import { CheckCircle } from 'lucide-react';
import type {
TransactionResult,
@@ -61,12 +63,19 @@ function LegacyMintPanel() {
};
if (result) {
+ const receipt = mapAdminActionReceipt({
+ operation: 'mint',
+ target: cleanAddress,
+ outcome: result,
+ network,
+ metadata: { amount: MINT_AMOUNT.toLocaleString('en-US') },
+ });
+
return (
-
);
}
diff --git a/src/features/admin/components/ComplianceUpdateModal.tsx b/src/features/admin/components/ComplianceUpdateModal.tsx
index a2c8405..4182926 100644
--- a/src/features/admin/components/ComplianceUpdateModal.tsx
+++ b/src/features/admin/components/ComplianceUpdateModal.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import TransactionReceipt from '@/components/transactions/TransactionReceipt';
import TransactionReviewModal from '@/components/transactions/TransactionReviewModal';
import { buildComplianceUpdateSummary } from '@/components/transactions/operationSummary';
+import { getExplorerUrl } from '@/components/transactions/explorerLink';
import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview';
import type { ComplianceSubject, BulkAction } from '@/lib/complianceReview';
import type { TransactionResult } from '@/components/transactions/types';
@@ -78,6 +79,12 @@ export default function ComplianceUpdateModal({
result={result}
details={details}
onClose={onClose}
+ explorerUrl={getExplorerUrl(result.txHash, network)}
+ limitation={
+ result.txHash
+ ? undefined
+ : 'Bulk compliance updates are applied locally in this dashboard build. No on-chain transaction hash or explorer link is available until a provider-backed write lands.'
+ }
/>
)}
diff --git a/src/features/admin/receipts/AdminActionReceiptView.test.tsx b/src/features/admin/receipts/AdminActionReceiptView.test.tsx
new file mode 100644
index 0000000..32d4254
--- /dev/null
+++ b/src/features/admin/receipts/AdminActionReceiptView.test.tsx
@@ -0,0 +1,75 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import AdminActionReceiptView from './AdminActionReceiptView';
+import {
+ assetRegistrationReceiptFixture,
+ mintReceiptFixture,
+ whitelistRemoveReceiptFixture,
+} from './fixtures';
+
+describe('AdminActionReceiptView', () => {
+ it('shows status, operation, target, hash, explorer link, and next action', () => {
+ const onNextAction = vi.fn();
+ const onClose = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/mint · success/i)).toBeInTheDocument();
+ expect(screen.getByText('Mint')).toBeInTheDocument();
+ expect(screen.getByText(mintReceiptFixture.target)).toBeInTheDocument();
+ expect(screen.getByText(/b9d0e\.\.\.4d5e/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: /view on stellar expert/i }),
+ ).toHaveAttribute(
+ 'href',
+ mintReceiptFixture.explorerUrl,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: /mint another/i }));
+ expect(onNextAction).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole('button', { name: /^close$/i }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows failure status and missing-hash limitation', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/whitelist · failed/i)).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ expect(screen.getByText(/did not return a transaction hash/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /review action/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('explains local asset-registration receipt limitations', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/asset registration · success/i)).toBeInTheDocument();
+ expect(screen.getByText('ISS-005')).toBeInTheDocument();
+ expect(screen.getByText(/local issuance request/i)).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /create another/i }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/features/admin/receipts/AdminActionReceiptView.tsx b/src/features/admin/receipts/AdminActionReceiptView.tsx
new file mode 100644
index 0000000..6896cff
--- /dev/null
+++ b/src/features/admin/receipts/AdminActionReceiptView.tsx
@@ -0,0 +1,37 @@
+import TransactionReceipt from '@/components/transactions/TransactionReceipt';
+import type { AdminActionReceipt } from './types';
+
+export interface AdminActionReceiptViewProps {
+ receipt: AdminActionReceipt;
+ /** Performs the operation-specific next step described by the receipt. */
+ onNextAction: () => void;
+ /** Dismisses the receipt without starting another action. */
+ onClose: () => void;
+}
+
+/**
+ * Receipt view for privileged dashboard actions.
+ *
+ * The mapper owns status/operation/target/hash/explorer/next-action semantics;
+ * this component reuses the shared transaction receipt presentation so admin
+ * and investor outcomes remain visually and behaviorally consistent.
+ */
+export default function AdminActionReceiptView({
+ receipt,
+ onNextAction,
+ onClose,
+}: AdminActionReceiptViewProps) {
+ return (
+
+ );
+}
diff --git a/src/features/admin/receipts/fixtures.ts b/src/features/admin/receipts/fixtures.ts
new file mode 100644
index 0000000..0296aaf
--- /dev/null
+++ b/src/features/admin/receipts/fixtures.ts
@@ -0,0 +1,73 @@
+import { mapAdminActionReceipt } from './mapAdminActionReceipt';
+import type { AdminActionReceipt } from './types';
+
+const TARGET =
+ 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3';
+
+const HASH =
+ 'b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e';
+
+export const whitelistAddReceiptFixture = mapAdminActionReceipt({
+ operation: 'whitelist-add',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'SUCCESS', hash: HASH },
+ metadata: { note: 'KYC case ref-001' },
+});
+
+export const whitelistRemoveReceiptFixture = mapAdminActionReceipt({
+ operation: 'whitelist-remove',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'FAILED', errorMessage: 'Admin authorization failed.' },
+});
+
+export const assetRegistrationReceiptFixture = mapAdminActionReceipt({
+ operation: 'asset-registration',
+ target: 'NY-CRE',
+ outcome: { status: 'SUCCESS' },
+ metadata: {
+ asset: 'Manhattan Commercial Real Estate (NY-CRE)',
+ amount: '100,000.00',
+ requestId: 'ISS-005',
+ },
+});
+
+export const roleChangeReceiptFixture = mapAdminActionReceipt({
+ operation: 'role-change',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'PENDING', hash: HASH },
+ metadata: { role: 'Issuer' },
+});
+
+export const mintReceiptFixture = mapAdminActionReceipt({
+ operation: 'mint',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'SUCCESS', hash: HASH },
+ metadata: { asset: 'NY-CRE', amount: '1,000.00 NY-CRE' },
+});
+
+/** Major admin operations required by Issue #179. */
+export const adminOperationReceiptFixtures: AdminActionReceipt[] = [
+ whitelistAddReceiptFixture,
+ whitelistRemoveReceiptFixture,
+ assetRegistrationReceiptFixture,
+ roleChangeReceiptFixture,
+ mintReceiptFixture,
+];
+
+/** Every receipt status rendered by the view. */
+export const adminReceiptStateFixtures: AdminActionReceipt[] = [
+ mintReceiptFixture,
+ whitelistRemoveReceiptFixture,
+ roleChangeReceiptFixture,
+ mapAdminActionReceipt({
+ operation: 'mint',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'not-a-known-status', hash: HASH },
+ metadata: { asset: 'NY-CRE', amount: '1,000.00 NY-CRE' },
+ }),
+];
diff --git a/src/features/admin/receipts/index.ts b/src/features/admin/receipts/index.ts
new file mode 100644
index 0000000..e6a292d
--- /dev/null
+++ b/src/features/admin/receipts/index.ts
@@ -0,0 +1,10 @@
+export { default as AdminActionReceiptView } from './AdminActionReceiptView';
+export { mapAdminActionReceipt } from './mapAdminActionReceipt';
+export * from './fixtures';
+export type {
+ AdminActionOperation,
+ AdminActionReceipt,
+ AdminActionReceiptInput,
+ AdminActionReceiptMetadata,
+ AdminReceiptNextAction,
+} from './types';
diff --git a/src/features/admin/receipts/mapAdminActionReceipt.test.ts b/src/features/admin/receipts/mapAdminActionReceipt.test.ts
new file mode 100644
index 0000000..3c33584
--- /dev/null
+++ b/src/features/admin/receipts/mapAdminActionReceipt.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest';
+import {
+ adminOperationReceiptFixtures,
+ adminReceiptStateFixtures,
+} from './fixtures';
+import { mapAdminActionReceipt } from './mapAdminActionReceipt';
+
+const TARGET =
+ 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3';
+const HASH =
+ 'b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e';
+
+describe('mapAdminActionReceipt', () => {
+ it('normalizes SDK status, target, hash, explorer URL, and next action', () => {
+ const receipt = mapAdminActionReceipt({
+ operation: 'mint',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'SUCCESS', hash: HASH },
+ metadata: { asset: 'NY-CRE', amount: '1,000.00 NY-CRE' },
+ });
+
+ expect(receipt.result).toMatchObject({
+ status: 'success',
+ txHash: HASH,
+ });
+ expect(receipt.details.rows).toEqual(
+ expect.arrayContaining([
+ { label: 'Operation', value: 'Mint' },
+ { label: 'Target', value: TARGET, mono: true },
+ { label: 'Asset', value: 'NY-CRE' },
+ { label: 'Network', value: 'TESTNET' },
+ ]),
+ );
+ expect(receipt.explorerUrl).toBe(
+ `https://stellar.expert/explorer/testnet/tx/${HASH}`,
+ );
+ expect(receipt.nextAction.label).toBe('Mint another');
+ expect(receipt.limitation).toBeUndefined();
+ });
+
+ it('explains when a provider returns no transaction hash', () => {
+ const receipt = mapAdminActionReceipt({
+ operation: 'whitelist-remove',
+ target: TARGET,
+ network: 'TESTNET',
+ outcome: { status: 'FAILED', errorMessage: 'Not authorized' },
+ });
+
+ expect(receipt.result.status).toBe('failure');
+ expect(receipt.explorerUrl).toBeNull();
+ expect(receipt.nextAction.label).toBe('Review action');
+ expect(receipt.limitation).toMatch(/did not return a transaction hash/i);
+ });
+
+ it('documents asset registration as local without chain evidence', () => {
+ const receipt = mapAdminActionReceipt({
+ operation: 'asset-registration',
+ target: 'NY-CRE',
+ outcome: { status: 'SUCCESS' },
+ metadata: { requestId: 'ISS-005', asset: 'New York CRE (NY-CRE)' },
+ });
+
+ expect(receipt.result.status).toBe('success');
+ expect(receipt.result.message).toBe('Request submitted');
+ expect(receipt.explorerUrl).toBeNull();
+ expect(receipt.limitation).toMatch(/local issuance request/i);
+ expect(receipt.nextAction.label).toBe('Create another');
+ });
+
+ it('does not create an explorer link for unsupported networks', () => {
+ const receipt = mapAdminActionReceipt({
+ operation: 'role-change',
+ target: TARGET,
+ network: 'FUTURENET',
+ outcome: { status: 'PENDING', hash: HASH },
+ metadata: { role: 'Issuer' },
+ });
+
+ expect(receipt.result.status).toBe('pending');
+ expect(receipt.explorerUrl).toBeNull();
+ expect(receipt.limitation).toMatch(/not wired/i);
+ });
+});
+
+describe('admin receipt fixtures', () => {
+ it('covers all major admin operations', () => {
+ expect(
+ adminOperationReceiptFixtures.map((receipt) => receipt.operation),
+ ).toEqual([
+ 'whitelist-add',
+ 'whitelist-remove',
+ 'asset-registration',
+ 'role-change',
+ 'mint',
+ ]);
+ });
+
+ it('covers every receipt status', () => {
+ expect(
+ adminReceiptStateFixtures.map((receipt) => receipt.result.status),
+ ).toEqual(['success', 'failure', 'pending', 'unknown']);
+ });
+});
diff --git a/src/features/admin/receipts/mapAdminActionReceipt.ts b/src/features/admin/receipts/mapAdminActionReceipt.ts
new file mode 100644
index 0000000..df7e521
--- /dev/null
+++ b/src/features/admin/receipts/mapAdminActionReceipt.ts
@@ -0,0 +1,205 @@
+import { getExplorerUrl } from '@/components/transactions/explorerLink';
+import { mapToTransactionResult } from '@/components/transactions/statusMapper';
+import type {
+ TransactionAction,
+ TransactionDetailRow,
+ TransactionResult,
+ TransactionStatus,
+} from '@/components/transactions/types';
+import type {
+ AdminActionOperation,
+ AdminActionReceipt,
+ AdminActionReceiptInput,
+ AdminReceiptNextAction,
+} from './types';
+
+interface OperationPresentation {
+ action: TransactionAction;
+ label: string;
+ title: string;
+}
+
+const OPERATION_PRESENTATION: Record<
+ AdminActionOperation,
+ OperationPresentation
+> = {
+ 'whitelist-add': {
+ action: 'whitelist',
+ label: 'Whitelist add',
+ title: 'Whitelist update receipt',
+ },
+ 'whitelist-remove': {
+ action: 'whitelist',
+ label: 'Whitelist revoke',
+ title: 'Whitelist update receipt',
+ },
+ 'asset-registration': {
+ action: 'asset-registration',
+ label: 'Asset registration',
+ title: 'Asset registration receipt',
+ },
+ 'role-change': {
+ action: 'role-change',
+ label: 'Role change',
+ title: 'Role change receipt',
+ },
+ mint: {
+ action: 'mint',
+ label: 'Mint',
+ title: 'Mint receipt',
+ },
+};
+
+const SUCCESS_NEXT_ACTION: Record<
+ AdminActionOperation,
+ AdminReceiptNextAction
+> = {
+ 'whitelist-add': {
+ label: 'Back to whitelist',
+ description: 'Review the updated address in whitelist management.',
+ },
+ 'whitelist-remove': {
+ label: 'Back to whitelist',
+ description: 'Review the revoked address in whitelist management.',
+ },
+ 'asset-registration': {
+ label: 'Create another',
+ description:
+ 'The request is pending compliance review; no asset was minted.',
+ },
+ 'role-change': {
+ label: 'Review role assignments',
+ description: 'Verify the account now has the intended protocol role.',
+ },
+ mint: {
+ label: 'Mint another',
+ description: 'Review the confirmed amount before starting another mint.',
+ },
+};
+
+const RECEIPT_STATUSES = new Set([
+ 'success',
+ 'failure',
+ 'pending',
+ 'unknown',
+]);
+
+function isTransactionResult(value: unknown): value is TransactionResult {
+ if (typeof value !== 'object' || value === null) return false;
+ const candidate = value as Partial;
+ return (
+ typeof candidate.status === 'string' &&
+ RECEIPT_STATUSES.has(candidate.status as TransactionStatus) &&
+ typeof candidate.message === 'string'
+ );
+}
+
+function nextActionFor(
+ operation: AdminActionOperation,
+ status: TransactionStatus,
+): AdminReceiptNextAction {
+ if (status === 'success') return SUCCESS_NEXT_ACTION[operation];
+
+ if (status === 'failure') {
+ return {
+ label: 'Review action',
+ description:
+ 'Check the target and permissions before attempting this action again.',
+ };
+ }
+
+ return {
+ label: 'Check transaction status',
+ description:
+ 'Verify the latest network state before retrying to avoid a duplicate action.',
+ };
+}
+
+function detailRows(input: AdminActionReceiptInput): TransactionDetailRow[] {
+ const presentation = OPERATION_PRESENTATION[input.operation];
+ const metadata = input.metadata;
+
+ return [
+ { label: 'Operation', value: presentation.label },
+ { label: 'Target', value: input.target, mono: true },
+ ...(metadata?.asset
+ ? [{ label: 'Asset', value: metadata.asset }]
+ : []),
+ ...(metadata?.amount
+ ? [{ label: 'Amount', value: metadata.amount }]
+ : []),
+ ...(metadata?.role ? [{ label: 'Role', value: metadata.role }] : []),
+ ...(metadata?.requestId
+ ? [{ label: 'Request ID', value: metadata.requestId, mono: true }]
+ : []),
+ ...(metadata?.note ? [{ label: 'Note', value: metadata.note }] : []),
+ { label: 'Network', value: input.network?.trim() || 'Not applicable' },
+ ];
+}
+
+function limitationFor(
+ operation: AdminActionOperation,
+ txHash: string | undefined,
+ explorerUrl: string | null,
+): string | undefined {
+ if (operation === 'asset-registration') {
+ return 'Asset registration is currently a local issuance request. It does not submit an on-chain transaction, so no transaction hash or explorer link is available.';
+ }
+
+ if (operation === 'role-change') {
+ return 'Role-change SDK submission is not wired in this dashboard yet. Fixture receipts document the expected view; live hash and explorer support depend on the provider response.';
+ }
+
+ if (!txHash) {
+ return 'The provider did not return a transaction hash. Confirm the action in the admin list or transaction history before retrying.';
+ }
+
+ if (!explorerUrl) {
+ return 'A transaction hash was returned, but this wallet network is not supported by the configured explorer link.';
+ }
+
+ return undefined;
+}
+
+/**
+ * Maps SDK/provider outcomes and local admin requests into one receipt model.
+ * Status normalization and explorer URL construction reuse the shared
+ * transaction helpers so admin receipts follow the same semantics as investor
+ * receipts.
+ */
+export function mapAdminActionReceipt(
+ input: AdminActionReceiptInput,
+): AdminActionReceipt {
+ const presentation = OPERATION_PRESENTATION[input.operation];
+ const mappedResult = isTransactionResult(input.outcome)
+ ? input.outcome
+ : mapToTransactionResult(input.outcome);
+ const result = {
+ ...mappedResult,
+ message:
+ mappedResult.status === 'success' &&
+ input.operation === 'asset-registration'
+ ? 'Request submitted'
+ : mappedResult.message,
+ };
+ const explorerUrl = getExplorerUrl(result.txHash, input.network);
+
+ return {
+ operation: input.operation,
+ target: input.target,
+ result,
+ explorerUrl,
+ nextAction: nextActionFor(input.operation, result.status),
+ limitation: limitationFor(
+ input.operation,
+ result.txHash,
+ explorerUrl,
+ ),
+ details: {
+ action: presentation.action,
+ title: presentation.title,
+ rows: detailRows(input),
+ network: input.network ?? undefined,
+ },
+ };
+}
diff --git a/src/features/admin/receipts/types.ts b/src/features/admin/receipts/types.ts
new file mode 100644
index 0000000..d0e9bae
--- /dev/null
+++ b/src/features/admin/receipts/types.ts
@@ -0,0 +1,50 @@
+import type {
+ TransactionDetails,
+ TransactionResult,
+} from '@/components/transactions/types';
+
+/** Major privileged operations represented by the admin receipt view. */
+export type AdminActionOperation =
+ | 'whitelist-add'
+ | 'whitelist-remove'
+ | 'asset-registration'
+ | 'role-change'
+ | 'mint';
+
+/** Extra operation data used to build receipt rows. */
+export interface AdminActionReceiptMetadata {
+ asset?: string;
+ amount?: string;
+ role?: string;
+ requestId?: string;
+ note?: string;
+}
+
+/** Raw input from an admin flow or SDK/provider outcome. */
+export interface AdminActionReceiptInput {
+ operation: AdminActionOperation;
+ target: string;
+ outcome: unknown;
+ network?: string | null;
+ metadata?: AdminActionReceiptMetadata;
+}
+
+export interface AdminReceiptNextAction {
+ label: string;
+ description: string;
+}
+
+/** Normalized model consumed by the admin receipt view. */
+export interface AdminActionReceipt {
+ operation: AdminActionOperation;
+ target: string;
+ details: TransactionDetails;
+ result: TransactionResult;
+ explorerUrl: string | null;
+ nextAction: AdminReceiptNextAction;
+ /**
+ * Explains missing chain evidence for local/mock actions or outcomes where a
+ * hash was not returned.
+ */
+ limitation?: string;
+}
diff --git a/src/features/asset-creation/components/AssetCreationWizard.tsx b/src/features/asset-creation/components/AssetCreationWizard.tsx
index ad61480..09f5d1d 100644
--- a/src/features/asset-creation/components/AssetCreationWizard.tsx
+++ b/src/features/asset-creation/components/AssetCreationWizard.tsx
@@ -7,6 +7,10 @@ import {
type AssetCreationErrorCode,
} from '@/lib/assetCreationRequest';
import { useFormErrors, FormFieldError, FormError } from '@/features/forms/validation';
+import {
+ AdminActionReceiptView,
+ mapAdminActionReceipt,
+} from '@/features/admin/receipts';
import type { IssuanceRequest } from '@/fixtures/issuer';
type WizardStep = 'form' | 'review' | 'success';
@@ -143,31 +147,24 @@ export default function AssetCreationWizard({
};
if (step === 'success' && lastCreated) {
+ const receipt = mapAdminActionReceipt({
+ operation: 'asset-registration',
+ target: lastCreated.ticker,
+ outcome: { status: 'SUCCESS' },
+ metadata: {
+ asset: `${lastCreated.assetName} (${lastCreated.ticker})`,
+ amount: lastCreated.amount.toLocaleString('en-US'),
+ requestId: lastCreated.id,
+ },
+ });
+
return (
-
Request submitted
-
- {lastCreated.ticker} has been
- submitted for compliance review with status{' '}
- pending. It will appear as
- mintable once approved.
-