diff --git a/README.md b/README.md
index d22729d..369bcad 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ Key resources for contributors:
- [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
- [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
- [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
diff --git a/docs/README.md b/docs/README.md
index 2ce5c05..fbb96ee 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) |
+| [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 |
| [audit-log.md](audit-log.md) | Audit log data model, filtering, safe CSV/JSON export, redaction |
diff --git a/docs/asset-lifecycle-status.md b/docs/asset-lifecycle-status.md
new file mode 100644
index 0000000..32e70bb
--- /dev/null
+++ b/docs/asset-lifecycle-status.md
@@ -0,0 +1,112 @@
+# RWA Asset Lifecycle Status
+
+Closes #30. Documents the issuer-controlled operational lifecycle of an
+already-minted RWA asset, and the UI used to display and (optionally) manage
+it.
+
+## Scope of this document
+This covers the lifecycle **state machine and status display** only. It does
+not duplicate two things that already exist elsewhere:
+- **Pre-mint issuance approval** (draft → pending → approved → minted →
+ rejected) is `IssuanceRequest.status` in `src/fixtures/issuer.ts` and
+ `src/features/issuer/components/IssuanceRequestsTable.tsx`. Lifecycle state
+ only begins once an asset has reached `minted` there.
+- **Per-wallet transfer eligibility** is `src/lib/eligibility.ts`
+ (Issue #55). Lifecycle state is asset-wide and issuer-driven; a paused
+ asset will typically also affect eligibility (via the existing
+ `assetPaused` flag on `AssetRestriction`), but this module does not compute
+ eligibility itself — see "Known Limitation" below.
+
+## Data Model
+See `src/lib/assetLifecycle.ts` — a pure, framework-free module with no
+React or SDK imports:
+- `AssetLifecycleState` — `'active' | 'paused' | 'matured' | 'redeemed' | 'defaulted'`
+- `AssetLifecycleEvent` — a single state entry with timestamp and optional note
+- `AssetLifecycleStatus` — current state, since-timestamp, and full history
+- `LIFECYCLE_STATE_INFO` — label/detail/tone metadata per state, used by the badge and timeline UI
+- `validateTransition` / `applyLifecycleTransition` — pure, fail-closed transition validation
+
+## State Machine
+
+```
+active ──▶ paused ──▶ active (resume)
+active ──▶ matured ──▶ redeemed
+active ──▶ defaulted ──▶ redeemed (wind-down / write-off)
+paused ──▶ defaulted ──▶ redeemed
+redeemed = terminal (no further transitions)
+```
+
+Any transition not shown above is rejected by `validateTransition`,
+including same-state "transitions" and anything attempted from `redeemed`.
+
+## UI Components
+- `src/features/assets/components/AssetLifecycleBadge.tsx` — small badge
+ (label + tone-coloured background), same pattern as the existing
+ `ComplianceBadge` / `TransferEligibilityBadge`.
+- `src/features/assets/components/AssetLifecycleTimeline.tsx` — the fuller
+ status UI: current badge, detail copy, ordered history, and (only when an
+ `onTransition` callback is supplied) one button per allowed next state.
+ Read-only by default; this component never calls the SDK itself.
+
+## Integration
+`PortfolioAsset` in `src/lib/aegis/types.ts` gained an **optional**
+`lifecycleStatus?: AssetLifecycleStatus` field — additive, so it does not
+break any existing consumer or test that constructs a `PortfolioAsset`
+without it. `AssetCard` renders `AssetLifecycleBadge` next to the existing
+compliance badge only when `lifecycleStatus` is present.
+
+`src/fixtures/portfolio.ts` was updated to exercise three states
+(`active`, `matured`, `paused`) plus the **absence** of the field entirely
+on the asset that already has `isDataAvailable: false` — this is a
+deliberate edge case: the UI must not assume a default state (e.g. "active")
+when lifecycle data could not be resolved.
+
+## Edge Cases Handled
+| Case | Behavior |
+|---|---|
+| Same-state "transition" (e.g. active → active) | Rejected with a clear reason |
+| Transition attempted from a terminal state (`redeemed`) | Rejected |
+| Transition that skips required states (e.g. active → redeemed directly) | Rejected, reason lists the actually-allowed next states |
+| Unrecognized/malformed state string reaching the validator | Rejected rather than throwing |
+| `lifecycleStatus` absent (SDK could not resolve it) | Badge/timeline simply do not render; no default assumed |
+| History rendering | Ordered oldest-first; notes shown when present |
+
+## Security & Compliance Assumptions
+- Lifecycle state reflects **issuer-reported operational status only** — it
+ is not a legal or financial determination about the asset, its
+ performance, or investment safety. `LIFECYCLE_STATE_INFO` wording is
+ written to avoid implying otherwise (see the compliance-safe-wording test
+ in `assetLifecycle.test.ts`), consistent with
+ `docs/compliance-safe-wording.md`.
+- This module does not gate transfers, minting, or any SDK call by itself.
+ A `paused` or `defaulted` lifecycle state does **not** automatically block
+ a transfer in the current codebase — that would need to flow through
+ `src/lib/eligibility.ts`'s existing `assetPaused` input. Wiring the two
+ together (e.g. deriving `assetPaused` from `lifecycleStatus.current`) is a
+ natural follow-up, intentionally left out of this change to keep this PR
+ focused and reviewable.
+- `AssetLifecycleTimeline`'s action buttons are UI affordances only; no
+ admin page in this PR actually wires `onTransition` to a real mutation.
+ That's a real gap for a future issue (e.g. an issuer-facing lifecycle
+ management page, sibling to `IssuanceRequestsTable`) but out of scope
+ here — this PR delivers the model, validation, display, and integration
+ point.
+
+## Testing
+- `src/lib/assetLifecycle.test.ts` — 21 tests covering terminal-state
+ detection, allowed-next-state lookups, transition validation (valid and
+ every rejected case above), pure `applyLifecycleTransition` behavior
+ (including that the input status is never mutated), and a
+ compliance-safe-wording check on the default copy.
+- `src/features/assets/components/AssetLifecycleTimeline.test.tsx` — 6
+ tests covering badge/detail rendering, ordered history with notes,
+ read-only mode (no buttons), one button per allowed next state, the
+ `onTransition` callback firing with the correct argument, and the
+ terminal-state messaging path.
+- Full suite (`npm run test`) passes at 325/325 after this change, with zero
+ regressions to any pre-existing test.
+
+## Related Documentation
+- [Investor Transfer Eligibility](investor-transfer-eligibility.md) — per-wallet eligibility gating (Issue #55)
+- [Investor Transfer Request Flow](investor-transfer-request-flow.md) — request-validation layer (Issue #41)
+- [Compliance-Safe Wording Guidance](compliance-safe-wording.md) — canonical disclaimer conventions
diff --git a/src/features/assets/components/AssetCard.tsx b/src/features/assets/components/AssetCard.tsx
index f681d07..4983d35 100644
--- a/src/features/assets/components/AssetCard.tsx
+++ b/src/features/assets/components/AssetCard.tsx
@@ -1,6 +1,7 @@
import { formatAmount } from '@/utils/formatting';
import ComplianceBadge from './ComplianceBadge';
import TransferEligibilityBadge from './TransferEligibilityBadge';
+import AssetLifecycleBadge from './AssetLifecycleBadge';
import type { PortfolioAsset } from '@/lib/aegis/types';
interface AssetCardProps {
@@ -9,7 +10,7 @@ interface AssetCardProps {
}
export default function AssetCard({ asset, onTransferClick }: AssetCardProps) {
- const { name, ticker, balance, metadata, compliance, transferEligibility, isDataAvailable } = asset;
+ const { name, ticker, balance, metadata, compliance, transferEligibility, lifecycleStatus, isDataAvailable } = asset;
const canTransfer = isDataAvailable && transferEligibility.state === 'eligible';
return (
@@ -21,7 +22,10 @@ export default function AssetCard({ asset, onTransferClick }: AssetCardProps) {
{ticker}
- {isDataAvailable && }
+
+ {isDataAvailable && }
+ {lifecycleStatus && }
+
{isDataAvailable ? (
diff --git a/src/features/assets/components/AssetLifecycleBadge.tsx b/src/features/assets/components/AssetLifecycleBadge.tsx
new file mode 100644
index 0000000..e731861
--- /dev/null
+++ b/src/features/assets/components/AssetLifecycleBadge.tsx
@@ -0,0 +1,26 @@
+import type { AssetLifecycleState } from '@/lib/assetLifecycle';
+import { LIFECYCLE_STATE_INFO, type LifecycleTone } from '@/lib/assetLifecycle';
+
+const TONE_STYLES: Record = {
+ positive: 'bg-emerald-50 text-emerald-700 border-emerald-200',
+ neutral: 'bg-slate-50 text-slate-700 border-slate-200',
+ caution: 'bg-amber-50 text-amber-700 border-amber-200',
+ negative: 'bg-red-50 text-red-700 border-red-200',
+};
+
+interface AssetLifecycleBadgeProps {
+ state: AssetLifecycleState;
+}
+
+export default function AssetLifecycleBadge({ state }: AssetLifecycleBadgeProps) {
+ const info = LIFECYCLE_STATE_INFO[state];
+
+ return (
+
+ {info.label}
+
+ );
+}
diff --git a/src/features/assets/components/AssetLifecycleTimeline.test.tsx b/src/features/assets/components/AssetLifecycleTimeline.test.tsx
new file mode 100644
index 0000000..0f2d8bd
--- /dev/null
+++ b/src/features/assets/components/AssetLifecycleTimeline.test.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import AssetLifecycleTimeline from '@/features/assets/components/AssetLifecycleTimeline';
+import type { AssetLifecycleStatus } from '@/lib/assetLifecycle';
+
+const ACTIVE_STATUS: AssetLifecycleStatus = {
+ current: 'active',
+ since: '2026-01-15T00:00:00Z',
+ history: [{ state: 'active', occurredAt: '2026-01-15T00:00:00Z', note: 'Asset issued and activated.' }],
+};
+
+const REDEEMED_STATUS: AssetLifecycleStatus = {
+ current: 'redeemed',
+ since: '2026-07-15T00:00:00Z',
+ history: [
+ { state: 'active', occurredAt: '2025-06-01T00:00:00Z' },
+ { state: 'matured', occurredAt: '2026-06-01T00:00:00Z' },
+ { state: 'redeemed', occurredAt: '2026-07-15T00:00:00Z', note: 'Full redemption completed.' },
+ ],
+};
+
+describe('AssetLifecycleTimeline', () => {
+ it('renders the current state badge and detail copy', () => {
+ render();
+ expect(screen.getAllByText('Active').length).toBeGreaterThan(0);
+ expect(screen.getByText(/live/i)).toBeInTheDocument();
+ });
+
+ it('renders the full history in order, including notes', () => {
+ render();
+ expect(screen.getByText('Full redemption completed.')).toBeInTheDocument();
+ // All three historical labels should appear somewhere (badge + list).
+ expect(screen.getAllByText('Redeemed').length).toBeGreaterThan(0);
+ expect(screen.getByText('Matured')).toBeInTheDocument();
+ });
+
+ it('does not render action buttons in read-only mode (no onTransition)', () => {
+ render();
+ expect(screen.queryByText(/available actions/i)).not.toBeInTheDocument();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('renders one action button per allowed next state when onTransition is provided', () => {
+ const onTransition = vi.fn();
+ render();
+
+ expect(screen.getByText('Mark as Paused')).toBeInTheDocument();
+ expect(screen.getByText('Mark as Matured')).toBeInTheDocument();
+ expect(screen.getByText('Mark as Default')).toBeInTheDocument();
+ });
+
+ it('calls onTransition with the correct next state when an action button is clicked', () => {
+ const onTransition = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByText('Mark as Paused'));
+
+ expect(onTransition).toHaveBeenCalledTimes(1);
+ expect(onTransition).toHaveBeenCalledWith('paused');
+ });
+
+ it('shows a terminal-state message instead of actions when in a terminal state', () => {
+ const onTransition = vi.fn();
+ render();
+
+ expect(screen.getByText(/terminal state/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/features/assets/components/AssetLifecycleTimeline.tsx b/src/features/assets/components/AssetLifecycleTimeline.tsx
new file mode 100644
index 0000000..68ad8f6
--- /dev/null
+++ b/src/features/assets/components/AssetLifecycleTimeline.tsx
@@ -0,0 +1,70 @@
+import AssetLifecycleBadge from './AssetLifecycleBadge';
+import {
+ LIFECYCLE_STATE_INFO,
+ getAllowedNextStates,
+ type AssetLifecycleStatus,
+ type AssetLifecycleState,
+} from '@/lib/assetLifecycle';
+
+interface AssetLifecycleTimelineProps {
+ status: AssetLifecycleStatus;
+ /**
+ * When provided, renders a button for each allowed next state and calls
+ * this with the chosen state on click. Omit for a read-only view (e.g. the
+ * investor-facing portfolio) — this component does not call the SDK
+ * itself; wiring an actual transition through `useAegis`/a real mutation
+ * is left to the caller.
+ */
+ onTransition?: (next: AssetLifecycleState) => void;
+}
+
+export default function AssetLifecycleTimeline({ status, onTransition }: AssetLifecycleTimelineProps) {
+ const allowedNext = getAllowedNextStates(status.current);
+ const currentInfo = LIFECYCLE_STATE_INFO[status.current];
+
+ return (
+
+
+
+ since {new Date(status.since).toLocaleDateString()}
+