Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
112 changes: 112 additions & 0 deletions docs/asset-lifecycle-status.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions src/features/assets/components/AssetCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
Expand All @@ -21,7 +22,10 @@ export default function AssetCard({ asset, onTransferClick }: AssetCardProps) {
{ticker}
</span>
</div>
{isDataAvailable && <ComplianceBadge compliance={compliance} />}
<div className="flex flex-col items-end gap-1">
{isDataAvailable && <ComplianceBadge compliance={compliance} />}
{lifecycleStatus && <AssetLifecycleBadge state={lifecycleStatus.current} />}
</div>
</div>

{isDataAvailable ? (
Expand Down
26 changes: 26 additions & 0 deletions src/features/assets/components/AssetLifecycleBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { AssetLifecycleState } from '@/lib/assetLifecycle';
import { LIFECYCLE_STATE_INFO, type LifecycleTone } from '@/lib/assetLifecycle';

const TONE_STYLES: Record<LifecycleTone, string> = {
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 (
<span
title={info.detail}
className={`inline-flex items-center text-xs font-semibold px-2 py-1 rounded border whitespace-nowrap ${TONE_STYLES[info.tone]}`}
>
{info.label}
</span>
);
}
70 changes: 70 additions & 0 deletions src/features/assets/components/AssetLifecycleTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AssetLifecycleTimeline status={ACTIVE_STATUS} />);
expect(screen.getAllByText('Active').length).toBeGreaterThan(0);
expect(screen.getByText(/live/i)).toBeInTheDocument();
});

it('renders the full history in order, including notes', () => {
render(<AssetLifecycleTimeline status={REDEEMED_STATUS} />);
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(<AssetLifecycleTimeline status={ACTIVE_STATUS} />);
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(<AssetLifecycleTimeline status={ACTIVE_STATUS} onTransition={onTransition} />);

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(<AssetLifecycleTimeline status={ACTIVE_STATUS} onTransition={onTransition} />);

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(<AssetLifecycleTimeline status={REDEEMED_STATUS} onTransition={onTransition} />);

expect(screen.getByText(/terminal state/i)).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
70 changes: 70 additions & 0 deletions src/features/assets/components/AssetLifecycleTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<AssetLifecycleBadge state={status.current} />
<span className="text-xs text-slate-500">since {new Date(status.since).toLocaleDateString()}</span>
</div>

<p className="text-sm text-slate-600">{currentInfo.detail}</p>

<ol className="space-y-2 border-l-2 border-slate-200 pl-4">
{status.history.map((event, i) => {
const info = LIFECYCLE_STATE_INFO[event.state];
return (
<li key={`${event.state}-${event.occurredAt}-${i}`} className="text-sm">
<span className="font-medium text-slate-800">{info.label}</span>
<span className="text-slate-400"> &middot; {new Date(event.occurredAt).toLocaleDateString()}</span>
{event.note && <p className="text-slate-500">{event.note}</p>}
</li>
);
})}
</ol>

{onTransition && allowedNext.length > 0 && (
<div>
<p className="text-xs text-slate-500 mb-2">Available actions</p>
<div className="flex flex-wrap gap-2">
{allowedNext.map((next) => (
<button
key={next}
type="button"
onClick={() => onTransition(next)}
className="text-xs font-medium px-3 py-1.5 rounded border border-slate-300 hover:bg-slate-50 transition"
>
Mark as {LIFECYCLE_STATE_INFO[next].label}
</button>
))}
</div>
</div>
)}

{onTransition && allowedNext.length === 0 && (
<p className="text-xs text-slate-400">This is a terminal state; no further transitions are available.</p>
)}
</div>
);
}
23 changes: 22 additions & 1 deletion src/fixtures/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export const mockPortfolioFixture: Omit<PortfolioReadModel, 'investorAddress' |
state: 'eligible',
reasons: [],
},
lifecycleStatus: {
current: 'active',
since: '2026-01-15T00:00:00Z',
history: [{ state: 'active', occurredAt: '2026-01-15T00:00:00Z', note: 'Asset issued and activated.' }],
},
isDataAvailable: true,
},
{
Expand All @@ -63,6 +68,14 @@ export const mockPortfolioFixture: Omit<PortfolioReadModel, 'investorAddress' |
state: 'eligible',
reasons: [],
},
lifecycleStatus: {
current: 'matured',
since: '2026-07-01T00:00:00Z',
history: [
{ state: 'active', occurredAt: '2026-01-01T00:00:00Z' },
{ state: 'matured', occurredAt: '2026-07-01T00:00:00Z', note: 'Reached scheduled maturity.' },
],
},
isDataAvailable: true,
},
{
Expand Down Expand Up @@ -90,6 +103,14 @@ export const mockPortfolioFixture: Omit<PortfolioReadModel, 'investorAddress' |
'Investor accreditation for EU private credit offerings is not on file.',
],
},
lifecycleStatus: {
current: 'paused',
since: '2026-07-20T00:00:00Z',
history: [
{ state: 'active', occurredAt: '2026-02-01T00:00:00Z' },
{ state: 'paused', occurredAt: '2026-07-20T00:00:00Z', note: 'Paused by the issuer pending compliance review.' },
],
},
isDataAvailable: true,
},
{
Expand Down Expand Up @@ -119,4 +140,4 @@ export const mockPortfolioFixture: Omit<PortfolioReadModel, 'investorAddress' |
isDataAvailable: false,
},
],
};
};
Loading
Loading