diff --git a/README.md b/README.md index 0123ab3..d53a26f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Key resources for contributors: - [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 +- [Compliance Status Panel](docs/compliance-status-panel.md) — Address-level compliance status for investor and admin views (Issue #175) - [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 533ee13..f57a936 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,7 @@ Reference material for contributors implementing new functionality. | [audit-log.md](audit-log.md) | Audit log data model, filtering, safe CSV/JSON export, redaction | | [compliance-reviewer-workflow.md](compliance-reviewer-workflow.md) | Compliance operator workflow for reviewing investor eligibility | | [compliance-safe-wording.md](compliance-safe-wording.md) | Compliance-safe wording guidance, shared disclaimer helper, and reviewer checklist | +| [compliance-status-panel.md](compliance-status-panel.md) | Address-level compliance status panel for investor/admin views (Issue #175) | | [performance-budget-review.md](performance-budget-review.md) | Typed budget threshold evaluation, edge cases, and reviewer checklist | | [feature-flags.md](feature-flags.md) | Feature flag hook, panel location, flag naming conventions | | [form-idempotency.md](form-idempotency.md) | Content-derived idempotency key, double-submit guard, TTL | diff --git a/docs/architecture.md b/docs/architecture.md index 63d6782..265313e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,4 +22,8 @@ The UI is strictly separated into pages and domain-specific features: - `src/features/auth/` encapsulates: - dashboard role model and route access config - SDK-backed role resolution (mocked) - - route guard hook and fixtures for access states \ No newline at end of file + - route guard hook and fixtures for access states +- `src/features/compliance/` encapsulates: + - address-level compliance status panel + - SDK raw-record mapping and safe explanatory copy + - fixtures covering approved / blocked / pending / revoked / unknown / unavailable \ No newline at end of file diff --git a/docs/compliance-safe-wording.md b/docs/compliance-safe-wording.md index 3857c82..13b5da7 100644 --- a/docs/compliance-safe-wording.md +++ b/docs/compliance-safe-wording.md @@ -94,6 +94,7 @@ withDisclaimer("") // → "Protocol-level compliance information. Not legal, reg ## Related - [Bulk Compliance Review](bulk-compliance-review.md) — Technical implementation of the compliance review table, including the `ComplianceUpdateModal` review modal (Issue #27) +- [Compliance Status Panel](compliance-status-panel.md) — Address-level status panel for investor and admin views (Issue #175) - [Compliance Update Modal](bulk-compliance-review.md#compliance-update-review-modal) — The confirmation modal that wraps `TransactionReview` and `TransactionReceipt` with the disclaimer - [Compliance Reviewer Workflow](compliance-reviewer-workflow.md) — Operator-facing workflow documentation - [SDK Error Recovery](sdk-error-recovery.md) — Recovery plans that also include compliance-safe wording diff --git a/docs/compliance-status-panel.md b/docs/compliance-status-panel.md new file mode 100644 index 0000000..e629399 --- /dev/null +++ b/docs/compliance-status-panel.md @@ -0,0 +1,80 @@ +# Compliance Status Panel + +Issue: [#175](https://github.com/Axionvera/aegis-dashboard/issues/175) + +## Goal + +Show investors and admins a clear, protocol-level compliance status for a wallet address. + +Supported states: + +- `approved` +- `blocked` +- `pending` +- `revoked` +- `unknown` +- `unavailable` + +## Where It Appears + +- Investor portfolio (`/portfolio`) — connected wallet status +- Admin dashboard (`/admin`) — connected wallet status plus address lookup + +## Architecture + +| Layer | Path | Responsibility | +|---|---|---| +| SDK contract | `src/lib/sdk/IAegisProvider.ts` | `getAddressCompliance(address)` | +| Mock / live providers | `src/lib/sdk/MockAegisProvider.ts`, `LiveAegisProvider.ts` | Centralised SDK access | +| Client stub | `src/lib/aegis/client.ts` | Live provider stub implementation | +| Hook | `src/hooks/useAegis.ts` | UI loading wrapper around provider | +| Mapper | `src/features/compliance/statusMap.ts` | Raw SDK record → panel model | +| Panel | `src/features/compliance/components/ComplianceStatusPanel.tsx` | Investor / admin UI | + +SDK calls are centralised through `getAegisProvider().getAddressCompliance(...)`. Components never call Freighter or contract RPCs directly for this panel. + +## Separation From Related Surfaces + +| Surface | Vocabulary | Purpose | +|---|---|---| +| This panel | `approved` / `blocked` / `pending` / `revoked` / `unknown` / `unavailable` | Address-level registry status | +| Asset badge (`lib/aegis/types`) | `compliant` / `restricted` / `pending_review` | Per-holding portfolio badge | +| Bulk review (`lib/complianceReview`) | `pending` / `approved` / `rejected` / `review` | Admin KYC queue actions | +| Static legend (`types/compliance`) | Same six public states as this panel | Reference copy only | + +Do not reuse transfer/onboarding eligibility (`evaluateEligibility`) for this panel. + +## Status Meaning + +| State | Meaning | +|---|---| +| Approved | Address is currently marked approved in the protocol registry | +| Blocked | Address is blocked from protocol actions | +| Pending | Review is still in progress | +| Revoked | Prior approval was revoked | +| Unknown | No clear record was returned | +| Unavailable | Registry / SDK data could not be retrieved | + +## Copy Assumptions + +- All explanations are **protocol-level only**. +- The panel always shows `COMPLIANCE_DISCLAIMER` from `src/lib/complianceReview.ts`. +- The panel explicitly states it does **not** perform real-world KYC. +- Copy must not claim legal approval, regulatory clearance, or financial advice. + +See also: [Compliance-Safe Wording Guidance](compliance-safe-wording.md). + +## Fixtures And Tests + +- Fixtures: `src/features/compliance/fixtures.ts` +- Mapper tests: `src/features/compliance/statusMap.test.ts` +- Panel tests: `src/features/compliance/components/ComplianceStatusPanel.test.tsx` + +## Mock Address Examples + +- Approved: `GCFXCOMPAPPROVED0000000000000000000000000000000000000` +- Blocked: `GCFXCOMPBLOCKED00000000000000000000000000000000000000` +- Pending: `GCFXCOMPPENDING00000000000000000000000000000000000000` +- Revoked: `GCFXCOMPREVOKED000000000000000000000000000000000000000` +- Unknown: `GCFXCOMPUNKNOWN00000000000000000000000000000000000000` +- Unavailable: `GCFXCOMPUNAVAILABLE000000000000000000000000000000000` diff --git a/src/__tests__/sdk/provider.test.ts b/src/__tests__/sdk/provider.test.ts index a4e11ee..08ed7ba 100644 --- a/src/__tests__/sdk/provider.test.ts +++ b/src/__tests__/sdk/provider.test.ts @@ -116,6 +116,25 @@ describe('MockAegisProvider', () => { expect(await provider.checkWhitelist('GABC')).toBe(false); }); + it('getAddressCompliance returns fixture status for known addresses', async () => { + const record = await provider.getAddressCompliance( + 'GCFXCOMPREVOKED000000000000000000000000000000000000000', + ); + expect(record.status).toBe('revoked'); + expect(record.address).toContain('REVOKED'); + }); + + it('getAddressCompliance falls back to approved for long G-addresses', async () => { + const address = 'G' + 'A'.repeat(54); + const record = await provider.getAddressCompliance(address); + expect(record.status).toBe('approved'); + }); + + it('getAddressCompliance returns unknown for unsupported addresses', async () => { + const record = await provider.getAddressCompliance('INVALID'); + expect(record.status).toBe('unknown'); + }); + it('transfer returns SUCCESS for a standard amount', async () => { const result = await provider.transfer('GCFXTEST', 100); expect(result.status).toBe('SUCCESS'); diff --git a/src/features/admin/components/ComplianceInfo.test.tsx b/src/features/admin/components/ComplianceInfo.test.tsx index 590d5e1..d905317 100644 --- a/src/features/admin/components/ComplianceInfo.test.tsx +++ b/src/features/admin/components/ComplianceInfo.test.tsx @@ -10,18 +10,18 @@ describe('ComplianceInfo', () => { const text = container.textContent || ''; expect(text).toContain('Approved'); - expect(text).toContain('Not Approved'); + expect(text).toContain('Revoked'); expect(text).toContain('Pending'); expect(text).toContain('Blocked'); expect(text).toContain('Unknown'); expect(text).toContain('Unavailable'); // Each status has an explanation - expect(text).toContain('fully approved'); - expect(text).toContain('denied approval'); - expect(text).toContain('under compliance review'); - expect(text).toContain('regulatory restrictions'); - expect(text).toContain('not yet determined'); - expect(text).toContain('temporarily unavailable'); + expect(text).toContain('marked approved in the protocol compliance registry'); + expect(text).toContain('has been revoked'); + expect(text).toContain('under protocol compliance review'); + expect(text).toContain('blocked from protocol actions'); + expect(text).toContain('No clear protocol compliance record'); + expect(text).toContain('could not be retrieved right now'); }); }); diff --git a/src/features/admin/components/ComplianceInfo.tsx b/src/features/admin/components/ComplianceInfo.tsx index 5759b44..bc85685 100644 --- a/src/features/admin/components/ComplianceInfo.tsx +++ b/src/features/admin/components/ComplianceInfo.tsx @@ -1,9 +1,8 @@ import { ComplianceStatus, COMPLIANCE_CONFIG } from '@/types/compliance'; -import ComplianceBadge from '@/features/assets/components/ComplianceBadge'; const ALL_STATUSES: ComplianceStatus[] = [ 'approved', - 'not-approved', + 'revoked', 'pending', 'blocked', 'unknown', diff --git a/src/features/compliance/components/ComplianceStatusPanel.test.tsx b/src/features/compliance/components/ComplianceStatusPanel.test.tsx new file mode 100644 index 0000000..084de03 --- /dev/null +++ b/src/features/compliance/components/ComplianceStatusPanel.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ComplianceStatusPanel from '@/features/compliance/components/ComplianceStatusPanel'; +import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; + +const mockUseComplianceStatus = vi.fn(); + +vi.mock('@/features/compliance/hooks/useComplianceStatus', () => ({ + useComplianceStatus: (...args: unknown[]) => mockUseComplianceStatus(...args), +})); + +describe('ComplianceStatusPanel', () => { + beforeEach(() => { + mockUseComplianceStatus.mockReset(); + }); + + it('prompts to connect when no address is provided', () => { + mockUseComplianceStatus.mockReturnValue({ + status: 'idle', + record: null, + error: null, + refetch: vi.fn(), + }); + + render(); + + expect(screen.getByText(/connect a wallet/i)).toBeInTheDocument(); + expect(screen.getByText(COMPLIANCE_DISCLAIMER)).toBeInTheDocument(); + }); + + it('renders approved status with safe disclaimer copy', () => { + mockUseComplianceStatus.mockReturnValue({ + status: 'ready', + record: { + address: 'GCFXCOMPAPPROVED0000000000000000000000000000000000000', + state: 'approved', + label: 'Approved', + explanation: + 'This address is currently marked approved in the protocol compliance registry for dashboard actions.', + reasonCode: 'REGISTRY_APPROVED', + source: 'sdk', + }, + error: null, + refetch: vi.fn(), + }); + + render(); + + expect(screen.getByText('Approved')).toBeInTheDocument(); + expect(screen.getByText(/protocol compliance registry/i)).toBeInTheDocument(); + expect(screen.getByText(COMPLIANCE_DISCLAIMER)).toBeInTheDocument(); + expect(screen.getByText(/does not perform real-world KYC/i)).toBeInTheDocument(); + }); + + it('renders revoked and unavailable labels distinctly', () => { + mockUseComplianceStatus.mockReturnValue({ + status: 'ready', + record: { + address: 'GCFXCOMPREVOKED000000000000000000000000000000000000000', + state: 'revoked', + label: 'Revoked', + explanation: 'Prior protocol approval for this address has been revoked.', + source: 'fixture', + }, + error: null, + refetch: vi.fn(), + }); + + const { rerender } = render( + , + ); + expect(screen.getByText('Revoked')).toBeInTheDocument(); + + mockUseComplianceStatus.mockReturnValue({ + status: 'ready', + record: { + address: 'GCFXCOMPUNAVAILABLE000000000000000000000000000000000', + state: 'unavailable', + label: 'Unavailable', + explanation: 'Compliance data could not be retrieved.', + source: 'fallback', + }, + error: null, + refetch: vi.fn(), + }); + + rerender( + , + ); + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + }); +}); diff --git a/src/features/compliance/components/ComplianceStatusPanel.tsx b/src/features/compliance/components/ComplianceStatusPanel.tsx new file mode 100644 index 0000000..604b212 --- /dev/null +++ b/src/features/compliance/components/ComplianceStatusPanel.tsx @@ -0,0 +1,167 @@ +import { + Ban, + CheckCircle, + Clock, + HelpCircle, + ShieldOff, + WifiOff, + type LucideIcon, +} from 'lucide-react'; +import { COMPLIANCE_DISCLAIMER } from '@/lib/complianceReview'; +import { truncateAddress } from '@/utils/formatting'; +import { useComplianceStatus } from '@/features/compliance/hooks/useComplianceStatus'; +import type { AddressComplianceState } from '@/features/compliance/types'; + +const STATE_STYLES: Record< + AddressComplianceState, + { badge: string; icon: LucideIcon } +> = { + approved: { + badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', + icon: CheckCircle, + }, + blocked: { + badge: 'bg-red-50 text-red-700 border-red-200', + icon: Ban, + }, + pending: { + badge: 'bg-amber-50 text-amber-700 border-amber-200', + icon: Clock, + }, + revoked: { + badge: 'bg-orange-50 text-orange-700 border-orange-200', + icon: ShieldOff, + }, + unknown: { + badge: 'bg-slate-100 text-slate-600 border-slate-200', + icon: HelpCircle, + }, + unavailable: { + badge: 'bg-slate-100 text-slate-600 border-slate-200', + icon: WifiOff, + }, +}; + +interface ComplianceStatusPanelProps { + /** Address to inspect. Defaults to the connected wallet when omitted by callers. */ + address: string | null; + /** Optional heading override for investor vs admin placement. */ + title?: string; + /** Compact layout for embedding beside portfolio cards. */ + compact?: boolean; +} + +export default function ComplianceStatusPanel({ + address, + title = 'Compliance Status', + compact = false, +}: ComplianceStatusPanelProps) { + const { status, record, error, refetch } = useComplianceStatus(address); + + if (!address) { + return ( +
+

{title}

+

+ Connect a wallet to view protocol-level compliance status for that address. +

+

{COMPLIANCE_DISCLAIMER}

+
+ ); + } + + if (status === 'idle' || status === 'loading') { + return ( +
+
+
+
+
+
+ ); + } + + if (!record) { + return ( +
+

{title}

+

+ {error ?? 'Compliance status is unavailable right now.'} +

+ +

{COMPLIANCE_DISCLAIMER}

+
+ ); + } + + const style = STATE_STYLES[record.state]; + const Icon = style.icon; + + return ( +
+
+
+

{title}

+

+ {truncateAddress(record.address)} +

+
+ + +
+ +

{record.explanation}

+ + {!compact && ( +
+ {record.reasonCode && ( +
+
Reason code
+
{record.reasonCode}
+
+ )} + {record.evaluatedAt && ( +
+
Last evaluated
+
{new Date(record.evaluatedAt).toLocaleString()}
+
+ )} +
+
Source
+
{record.source}
+
+
+ )} + + {status === 'error' && ( + + )} + +

{COMPLIANCE_DISCLAIMER}

+

+ This panel does not perform real-world KYC and is not a legal determination. +

+
+ ); +} diff --git a/src/features/compliance/fixtures.ts b/src/features/compliance/fixtures.ts new file mode 100644 index 0000000..fe2b2a9 --- /dev/null +++ b/src/features/compliance/fixtures.ts @@ -0,0 +1,71 @@ +/** + * Fixtures for address-level compliance status panel development and tests. + * + * All addresses are synthetic Stellar-style keys and do not represent real + * accounts or real KYC decisions. + */ + +import { mapAddressComplianceStatus } from '@/features/compliance/statusMap'; +import type { + AddressComplianceStatus, + RawAddressComplianceRecord, +} from '@/features/compliance/types'; + +export const addressComplianceFixtureInputs: RawAddressComplianceRecord[] = [ + { + address: 'GCFXCOMPAPPROVED0000000000000000000000000000000000000', + status: 'approved', + reasonCode: 'REGISTRY_APPROVED', + evaluatedAt: '2026-07-28T10:00:00.000Z', + detail: + 'This address is currently marked approved in the protocol compliance registry for dashboard actions.', + }, + { + address: 'GCFXCOMPBLOCKED00000000000000000000000000000000000000', + status: 'blocked', + reasonCode: 'REGISTRY_BLOCKED', + evaluatedAt: '2026-07-28T10:05:00.000Z', + detail: + 'This address is currently blocked from protocol actions by the compliance registry.', + }, + { + address: 'GCFXCOMPPENDING00000000000000000000000000000000000000', + status: 'pending', + reasonCode: 'AWAITING_REVIEW', + evaluatedAt: '2026-07-28T10:10:00.000Z', + detail: + 'Compliance review for this address is still pending in the protocol registry.', + }, + { + address: 'GCFXCOMPREVOKED000000000000000000000000000000000000000', + status: 'revoked', + reasonCode: 'APPROVAL_REVOKED', + evaluatedAt: '2026-07-28T10:15:00.000Z', + detail: + 'Prior protocol approval for this address has been revoked in the compliance registry.', + }, + { + address: 'GCFXCOMPUNKNOWN00000000000000000000000000000000000000', + status: 'unknown', + reasonCode: 'NO_RECORD', + evaluatedAt: '2026-07-28T10:20:00.000Z', + }, + { + address: 'GCFXCOMPUNAVAILABLE000000000000000000000000000000000', + status: 'unavailable', + unavailable: true, + reasonCode: 'REGISTRY_TIMEOUT', + detail: + 'Compliance data for this address could not be retrieved right now. Try again later.', + }, +]; + +export const addressComplianceFixtures: AddressComplianceStatus[] = + addressComplianceFixtureInputs.map((record) => + mapAddressComplianceStatus(record, 'fixture'), + ); + +/** Lookup table used by the mock SDK provider. */ +export const addressComplianceFixtureByAddress = Object.fromEntries( + addressComplianceFixtureInputs.map((record) => [record.address, record]), +) as Record; diff --git a/src/features/compliance/hooks/useComplianceStatus.ts b/src/features/compliance/hooks/useComplianceStatus.ts new file mode 100644 index 0000000..f780313 --- /dev/null +++ b/src/features/compliance/hooks/useComplianceStatus.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAegis } from '@/hooks/useAegis'; +import { + mapAddressComplianceStatus, + unavailableComplianceStatus, +} from '@/features/compliance/statusMap'; +import type { AddressComplianceStatus } from '@/features/compliance/types'; + +export type ComplianceStatusLoadState = 'idle' | 'loading' | 'ready' | 'error'; + +interface UseComplianceStatusResult { + status: ComplianceStatusLoadState; + record: AddressComplianceStatus | null; + error: string | null; + refetch: () => void; +} + +/** + * Loads address-level compliance status through the centralised Aegis SDK + * provider and maps it into the panel-safe model. + */ +export function useComplianceStatus( + address: string | null, +): UseComplianceStatusResult { + const { getAddressCompliance } = useAegis(); + const [status, setStatus] = useState('idle'); + const [record, setRecord] = useState(null); + const [error, setError] = useState(null); + const [refetchToken, setRefetchToken] = useState(0); + + useEffect(() => { + if (!address) { + setStatus('idle'); + setRecord(null); + setError(null); + return; + } + + let cancelled = false; + setStatus('loading'); + setError(null); + + getAddressCompliance(address) + .then((raw) => { + if (cancelled) return; + setRecord(mapAddressComplianceStatus(raw, 'sdk')); + setStatus('ready'); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = + err instanceof Error + ? err.message + : 'Unable to load compliance status right now.'; + setError(message); + setRecord(unavailableComplianceStatus(address, message)); + setStatus('error'); + }); + + return () => { + cancelled = true; + }; + // getAddressCompliance is recreated each render by useAegis; only address / + // explicit refetch should trigger a new request. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, refetchToken]); + + const refetch = useCallback(() => setRefetchToken((token) => token + 1), []); + + return { status, record, error, refetch }; +} diff --git a/src/features/compliance/index.ts b/src/features/compliance/index.ts new file mode 100644 index 0000000..d84a94a --- /dev/null +++ b/src/features/compliance/index.ts @@ -0,0 +1,5 @@ +export * from '@/features/compliance/types'; +export * from '@/features/compliance/statusMap'; +export * from '@/features/compliance/fixtures'; +export { useComplianceStatus } from '@/features/compliance/hooks/useComplianceStatus'; +export { default as ComplianceStatusPanel } from '@/features/compliance/components/ComplianceStatusPanel'; diff --git a/src/features/compliance/statusMap.test.ts b/src/features/compliance/statusMap.test.ts new file mode 100644 index 0000000..4702763 --- /dev/null +++ b/src/features/compliance/statusMap.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + ADDRESS_COMPLIANCE_PRESENTATION, + mapAddressComplianceStatus, + normalizeAddressComplianceState, + unavailableComplianceStatus, +} from '@/features/compliance/statusMap'; +import { addressComplianceFixtures } from '@/features/compliance/fixtures'; + +describe('normalizeAddressComplianceState', () => { + it('maps approved aliases', () => { + expect(normalizeAddressComplianceState('approved')).toBe('approved'); + expect(normalizeAddressComplianceState('COMPLIANT')).toBe('approved'); + expect(normalizeAddressComplianceState('whitelisted')).toBe('approved'); + }); + + it('maps blocked aliases including rejected', () => { + expect(normalizeAddressComplianceState('blocked')).toBe('blocked'); + expect(normalizeAddressComplianceState('rejected')).toBe('blocked'); + }); + + it('maps revoked aliases including not-approved', () => { + expect(normalizeAddressComplianceState('revoked')).toBe('revoked'); + expect(normalizeAddressComplianceState('not_approved')).toBe('revoked'); + }); + + it('maps pending, unknown, and unavailable', () => { + expect(normalizeAddressComplianceState('pending_review')).toBe('pending'); + expect(normalizeAddressComplianceState('something-else')).toBe('unknown'); + expect(normalizeAddressComplianceState('approved', true)).toBe('unavailable'); + }); +}); + +describe('mapAddressComplianceStatus', () => { + it('produces panel-safe records for every major state fixture', () => { + const states = new Set(addressComplianceFixtures.map((item) => item.state)); + expect(states.has('approved')).toBe(true); + expect(states.has('blocked')).toBe(true); + expect(states.has('pending')).toBe(true); + expect(states.has('revoked')).toBe(true); + expect(states.has('unknown')).toBe(true); + expect(states.has('unavailable')).toBe(true); + }); + + it('falls back to presentation copy when detail is missing', () => { + const mapped = mapAddressComplianceStatus({ + address: 'GTEST', + status: 'pending', + }); + + expect(mapped.label).toBe('Pending'); + expect(mapped.explanation).toBe(ADDRESS_COMPLIANCE_PRESENTATION.pending.explanation); + }); + + it('preserves provider detail when present', () => { + const mapped = mapAddressComplianceStatus({ + address: 'GTEST', + status: 'revoked', + detail: 'Custom protocol note', + }); + + expect(mapped.explanation).toBe('Custom protocol note'); + }); +}); + +describe('unavailableComplianceStatus', () => { + it('returns an unavailable fallback without throwing', () => { + const fallback = unavailableComplianceStatus('GTEST', 'timeout'); + expect(fallback.state).toBe('unavailable'); + expect(fallback.explanation).toBe('timeout'); + expect(fallback.source).toBe('fallback'); + }); +}); diff --git a/src/features/compliance/statusMap.ts b/src/features/compliance/statusMap.ts new file mode 100644 index 0000000..b3b42f4 --- /dev/null +++ b/src/features/compliance/statusMap.ts @@ -0,0 +1,128 @@ +import type { + AddressComplianceState, + AddressComplianceStatus, + RawAddressComplianceRecord, +} from '@/features/compliance/types'; + +interface StatePresentation { + label: string; + explanation: string; +} + +/** + * Protocol-level copy only. Never claims real-world KYC completion, + * legal approval, or regulatory clearance. + */ +export const ADDRESS_COMPLIANCE_PRESENTATION: Record< + AddressComplianceState, + StatePresentation +> = { + approved: { + label: 'Approved', + explanation: + 'This address is currently marked approved in the protocol compliance registry for dashboard actions.', + }, + blocked: { + label: 'Blocked', + explanation: + 'This address is currently blocked from protocol actions by the compliance registry.', + }, + pending: { + label: 'Pending', + explanation: + 'Compliance review for this address is still pending in the protocol registry.', + }, + revoked: { + label: 'Revoked', + explanation: + 'Prior protocol approval for this address has been revoked in the compliance registry.', + }, + unknown: { + label: 'Unknown', + explanation: + 'No clear compliance record was returned for this address from the protocol registry.', + }, + unavailable: { + label: 'Unavailable', + explanation: + 'Compliance data for this address could not be retrieved right now. Try again later.', + }, +}; + +const STATUS_ALIASES: Record = { + approved: 'approved', + approve: 'approved', + compliant: 'approved', + whitelisted: 'approved', + allowed: 'approved', + blocked: 'blocked', + block: 'blocked', + restricted: 'blocked', + denied: 'blocked', + rejected: 'blocked', + revoked: 'revoked', + revoke: 'revoked', + suspended: 'revoked', + 'not-approved': 'revoked', + not_approved: 'revoked', + notapproved: 'revoked', + pending: 'pending', + pending_review: 'pending', + 'pending-review': 'pending', + review: 'pending', + in_review: 'pending', + unknown: 'unknown', + unavailable: 'unavailable', + error: 'unavailable', + timeout: 'unavailable', +}; + +export function normalizeAddressComplianceState( + raw?: string | null, + unavailable = false, +): AddressComplianceState { + if (unavailable) return 'unavailable'; + if (!raw || !raw.trim()) return 'unknown'; + + const key = raw.trim().toLowerCase().replace(/\s+/g, '_'); + return STATUS_ALIASES[key] ?? 'unknown'; +} + +/** + * Maps a raw SDK / mock compliance record into the panel model. + * Always produces a safe fallback rather than throwing on malformed input. + */ +export function mapAddressComplianceStatus( + record: RawAddressComplianceRecord, + source: AddressComplianceStatus['source'] = 'sdk', +): AddressComplianceStatus { + const state = normalizeAddressComplianceState(record.status, Boolean(record.unavailable)); + const presentation = ADDRESS_COMPLIANCE_PRESENTATION[state]; + + return { + address: record.address, + state, + label: presentation.label, + explanation: record.detail?.trim() + ? record.detail.trim() + : presentation.explanation, + reasonCode: record.reasonCode?.trim() || undefined, + evaluatedAt: record.evaluatedAt?.trim() || undefined, + source, + }; +} + +export function unavailableComplianceStatus( + address: string, + detail?: string, +): AddressComplianceStatus { + return mapAddressComplianceStatus( + { + address, + status: 'unavailable', + unavailable: true, + detail, + }, + 'fallback', + ); +} diff --git a/src/features/compliance/types.ts b/src/features/compliance/types.ts new file mode 100644 index 0000000..97179c4 --- /dev/null +++ b/src/features/compliance/types.ts @@ -0,0 +1,48 @@ +/** + * Address-level compliance status for the dashboard status panel. + * + * Distinct from asset-level badges (`src/lib/aegis/types.ts`) and bulk review + * statuses (`src/lib/complianceReview.ts`). This model describes whether a + * wallet address is approved / blocked / pending / revoked / unknown / + * unavailable according to the protocol compliance registry. + */ + +export type AddressComplianceState = + | 'approved' + | 'blocked' + | 'pending' + | 'revoked' + | 'unknown' + | 'unavailable'; + +/** Raw shape expected from the SDK / mock provider before normalisation. */ +export interface RawAddressComplianceRecord { + address: string; + /** + * Provider-native state string. May use alternate spellings + * (e.g. `not_approved`, `REJECTED`) that the mapper normalises. + */ + status?: string | null; + /** Optional protocol-level reason code from the registry. */ + reasonCode?: string | null; + /** Optional free-form detail from the provider. */ + detail?: string | null; + /** ISO timestamp of the last registry evaluation, when known. */ + evaluatedAt?: string | null; + /** True when the provider could not reach the registry. */ + unavailable?: boolean; +} + +/** Normalised panel model consumed by ComplianceStatusPanel. */ +export interface AddressComplianceStatus { + address: string; + state: AddressComplianceState; + /** Short badge label, e.g. "Approved". */ + label: string; + /** Safe explanatory copy for investors/admins (protocol-level only). */ + explanation: string; + reasonCode?: string; + evaluatedAt?: string; + /** Where the record came from for diagnostics/debug. */ + source: 'sdk' | 'fixture' | 'fallback'; +} diff --git a/src/hooks/useAegis.ts b/src/hooks/useAegis.ts index 2a97320..43501d0 100644 --- a/src/hooks/useAegis.ts +++ b/src/hooks/useAegis.ts @@ -7,6 +7,7 @@ import type { TransactionPhase, } from '@/components/transactions/types'; import type { BudgetReviewResult } from '@/lib/performanceBudget'; +import type { RawAddressComplianceRecord } from '@/features/compliance/types'; import { resolveWalletRole } from '@/features/auth/resolveRole'; import { useTransactionHistoryStore } from '@/features/transactions/store'; import { useWallet } from '@/hooks/useWallet'; @@ -133,6 +134,17 @@ export const useAegis = () => { } }; + const getAddressCompliance = async ( + target: string, + ): Promise => { + setIsLoading(true); + try { + return await getAegisProvider().getAddressCompliance(target); + } finally { + setIsLoading(false); + } + }; + const transfer = async ( to: string, amount: number, @@ -209,6 +221,7 @@ export const useAegis = () => { listWhitelist, addToWhitelist, removeFromWhitelist, + getAddressCompliance, transfer, mint, getPortfolio, diff --git a/src/lib/aegis/client.ts b/src/lib/aegis/client.ts index 089f7cb..23b65ca 100644 --- a/src/lib/aegis/client.ts +++ b/src/lib/aegis/client.ts @@ -6,6 +6,8 @@ import type { TransactionPhase, } from '@/components/transactions/types'; import type { BudgetReviewResult } from '@/lib/performanceBudget'; +import type { RawAddressComplianceRecord } from '@/features/compliance/types'; +import { addressComplianceFixtureByAddress } from '@/features/compliance/fixtures'; import { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget'; /** @@ -185,6 +187,40 @@ export async function removeFromWhitelist( return { status: 'SUCCESS', hash: `mock_tx_hash_whitelist_remove_${Date.now()}` }; } +/** + * Mocks address-level compliance registry lookup. + * Returns a raw record for the dashboard status panel mapper. + */ +export async function getAddressCompliance( + address: string, +): Promise { + await wait(500); + + const fixture = addressComplianceFixtureByAddress[address]; + if (fixture) { + return { ...fixture, address }; + } + + const normalized = address.toUpperCase(); + if (normalized.includes('REVOKED')) { + return { address, status: 'revoked', reasonCode: 'APPROVAL_REVOKED' }; + } + if (normalized.includes('BLOCK') || normalized.includes('DENY')) { + return { address, status: 'blocked', reasonCode: 'REGISTRY_BLOCKED' }; + } + if (normalized.includes('PENDING') || normalized.includes('REVIEW')) { + return { address, status: 'pending', reasonCode: 'AWAITING_REVIEW' }; + } + if (normalized.includes('UNAVAILABLE') || normalized.includes('TIMEOUT')) { + return { address, status: 'unavailable', unavailable: true, reasonCode: 'REGISTRY_TIMEOUT' }; + } + if (address.startsWith('G') && address.length > 50) { + return { address, status: 'approved', reasonCode: 'REGISTRY_APPROVED' }; + } + + return { address, status: 'unknown', reasonCode: 'NO_RECORD' }; +} + /** Called as the transaction moves from wallet signature to network submission. */ type PhaseListener = (phase: TransactionPhase) => void; diff --git a/src/lib/sdk/IAegisProvider.ts b/src/lib/sdk/IAegisProvider.ts index 4964153..f191693 100644 --- a/src/lib/sdk/IAegisProvider.ts +++ b/src/lib/sdk/IAegisProvider.ts @@ -14,6 +14,7 @@ import type { TransactionPhase, } from '@/components/transactions/types'; import type { BudgetReviewResult } from '@/lib/performanceBudget'; +import type { RawAddressComplianceRecord } from '@/features/compliance/types'; /** Called as the transaction moves from wallet signature to network submission. */ export type PhaseListener = (phase: TransactionPhase) => void; @@ -54,6 +55,14 @@ export interface IAegisProvider { onPhase?: PhaseListener, ): Promise; + /** + * Fetch address-level compliance status from the protocol registry. + * Returns a raw record that the dashboard maps into panel-safe copy. + */ + getAddressCompliance( + address: string, + ): Promise; + /** * Initiate a compliant asset transfer. */ diff --git a/src/lib/sdk/LiveAegisProvider.ts b/src/lib/sdk/LiveAegisProvider.ts index 61f8562..11cb51f 100644 --- a/src/lib/sdk/LiveAegisProvider.ts +++ b/src/lib/sdk/LiveAegisProvider.ts @@ -19,6 +19,7 @@ import type { PortfolioReadModel } from '@/lib/aegis/types'; import type { WhitelistEntry } from '@/lib/whitelist'; import type { RawTransactionOutcome } from '@/components/transactions/types'; import type { BudgetReviewResult } from '@/lib/performanceBudget'; +import type { RawAddressComplianceRecord } from '@/features/compliance/types'; import * as aegisClient from '@/lib/aegis/client'; export class LiveAegisProvider implements IAegisProvider { @@ -52,6 +53,10 @@ export class LiveAegisProvider implements IAegisProvider { return aegisClient.removeFromWhitelist(address, actor, onPhase); } + getAddressCompliance(address: string): Promise { + return aegisClient.getAddressCompliance(address); + } + transfer( to: string, amount: number, diff --git a/src/lib/sdk/MockAegisProvider.ts b/src/lib/sdk/MockAegisProvider.ts index 6336fb1..037a361 100644 --- a/src/lib/sdk/MockAegisProvider.ts +++ b/src/lib/sdk/MockAegisProvider.ts @@ -22,6 +22,8 @@ import type { PortfolioReadModel } from '@/lib/aegis/types'; import type { WhitelistEntry } from '@/lib/whitelist'; import type { RawTransactionOutcome } from '@/components/transactions/types'; import type { BudgetReviewResult } from '@/lib/performanceBudget'; +import type { RawAddressComplianceRecord } from '@/features/compliance/types'; +import { addressComplianceFixtureByAddress } from '@/features/compliance/fixtures'; import { mockPortfolioFixture } from '@/fixtures/portfolio'; import { sampleBudgetResults } from '@/lib/__fixtures__/performanceBudget'; import { sampleWhitelistEntries } from '@/lib/__fixtures__/whitelist'; @@ -144,6 +146,34 @@ export class MockAegisProvider implements IAegisProvider { return { status: 'SUCCESS', hash: `mock_tx_hash_whitelist_remove_${Date.now()}` }; } + async getAddressCompliance(address: string): Promise { + await wait(450); + + const fixture = addressComplianceFixtureByAddress[address]; + if (fixture) { + return { ...fixture, address }; + } + + const normalized = address.toUpperCase(); + if (normalized.includes('REVOKED')) { + return { address, status: 'revoked', reasonCode: 'APPROVAL_REVOKED' }; + } + if (normalized.includes('BLOCK') || normalized.includes('DENY')) { + return { address, status: 'blocked', reasonCode: 'REGISTRY_BLOCKED' }; + } + if (normalized.includes('PENDING') || normalized.includes('REVIEW')) { + return { address, status: 'pending', reasonCode: 'AWAITING_REVIEW' }; + } + if (normalized.includes('UNAVAILABLE') || normalized.includes('TIMEOUT')) { + return { address, status: 'unavailable', unavailable: true, reasonCode: 'REGISTRY_TIMEOUT' }; + } + if (address.startsWith('G') && address.length > 50) { + return { address, status: 'approved', reasonCode: 'REGISTRY_APPROVED' }; + } + + return { address, status: 'unknown', reasonCode: 'NO_RECORD' }; + } + async transfer( to: string, amount: number, diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index d9c7dcb..4d7c49e 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -1,10 +1,23 @@ +import { useState, type FormEvent } from 'react'; import Head from 'next/head'; import AdminPanel from '@/features/admin/components/AdminPanel'; import FeatureFlagsPanel from '@/features/admin/components/FeatureFlagsPanel'; import WhitelistManager from '@/features/compliance/components/WhitelistManager'; +import ComplianceStatusPanel from '@/features/compliance/components/ComplianceStatusPanel'; import RouteGuard from '@/components/RouteGuard'; +import { useWallet } from '@/hooks/useWallet'; export default function Admin() { + const { address } = useWallet(); + const [lookupAddress, setLookupAddress] = useState(''); + const [activeLookup, setActiveLookup] = useState(null); + + const handleLookup = (event: FormEvent) => { + event.preventDefault(); + const cleaned = lookupAddress.trim(); + setActiveLookup(cleaned || null); + }; + return ( @@ -18,6 +31,43 @@ export default function Admin() { + + +
+
+

Address Compliance Lookup

+

+ Inspect protocol-level compliance status for any address before mint or whitelist actions. +

+
+
+ setLookupAddress(event.target.value)} + placeholder="GABC..." + className="flex-1 border border-slate-300 rounded p-2 focus:ring-2 focus:ring-aegis-brand outline-none" + aria-label="Lookup address" + /> + +
+ {activeLookup && ( + + )} +
+
diff --git a/src/pages/portfolio.tsx b/src/pages/portfolio.tsx index bec17ac..b4dbf5f 100644 --- a/src/pages/portfolio.tsx +++ b/src/pages/portfolio.tsx @@ -1,15 +1,23 @@ import Head from 'next/head'; import PortfolioList from '@/features/investor/components/PortfolioList'; +import ComplianceStatusPanel from '@/features/compliance/components/ComplianceStatusPanel'; import RouteGuard from '@/components/RouteGuard'; +import { useWallet } from '@/hooks/useWallet'; export default function Portfolio() { + const { address } = useWallet(); + return ( My Portfolio | Aegis RWA -
-

My Asset Portfolio

+
+

My Asset Portfolio

+
diff --git a/src/types/compliance.ts b/src/types/compliance.ts index 28fe198..6aa2ebb 100644 --- a/src/types/compliance.ts +++ b/src/types/compliance.ts @@ -1,16 +1,23 @@ import { CheckCircle, - XCircle, Clock, Ban, HelpCircle, + ShieldOff, WifiOff, type LucideIcon, } from 'lucide-react'; +/** + * Static reference legend statuses (asset/reference UI). + * + * Address-level panel statuses for Issue #175 live in + * `src/features/compliance/types.ts` and include the same public set with + * protocol-safe copy owned by `statusMap.ts`. + */ export type ComplianceStatus = | 'approved' - | 'not-approved' + | 'revoked' | 'pending' | 'blocked' | 'unknown' @@ -30,41 +37,44 @@ export const COMPLIANCE_CONFIG: Record textColor: 'text-emerald-700', bgColor: 'bg-emerald-50', icon: CheckCircle, - explanation: 'This asset has been fully approved for trading.', + explanation: + 'This item is currently marked approved in the protocol compliance registry.', }, - 'not-approved': { - label: 'Not Approved', - textColor: 'text-red-700', - bgColor: 'bg-red-50', - icon: XCircle, - explanation: 'This asset has been reviewed and denied approval.', + revoked: { + label: 'Revoked', + textColor: 'text-orange-700', + bgColor: 'bg-orange-50', + icon: ShieldOff, + explanation: + 'Prior protocol approval for this item has been revoked in the compliance registry.', }, pending: { label: 'Pending', textColor: 'text-amber-700', bgColor: 'bg-amber-50', icon: Clock, - explanation: 'This asset is currently under compliance review.', + explanation: 'This item is currently under protocol compliance review.', }, blocked: { label: 'Blocked', textColor: 'text-red-700', bgColor: 'bg-red-50', icon: Ban, - explanation: 'This asset has been blocked due to regulatory restrictions.', + explanation: + 'This item is currently blocked from protocol actions by the compliance registry.', }, unknown: { label: 'Unknown', textColor: 'text-slate-500', bgColor: 'bg-slate-100', icon: HelpCircle, - explanation: 'The compliance status of this asset is not yet determined.', + explanation: 'No clear protocol compliance record is available for this item.', }, unavailable: { label: 'Unavailable', textColor: 'text-slate-500', bgColor: 'bg-slate-100', icon: WifiOff, - explanation: 'Compliance data for this asset is temporarily unavailable.', + explanation: 'Compliance data for this item could not be retrieved right now.', }, };