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
5 changes: 0 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,6 @@ npm run lint
| Criterion 1 | How it was met |
| Criterion 2 | How it was met |

### 7. Self-Assessment

- [ ] The [Contributor Self-Assessment Form](../docs/self-assessment-checklist.md) has been completed and attached to this PR
- **Form Location:** <!-- e.g., pasted in Reviewer Notes, attached as a file comment -->

---

## Screenshots / Recordings
Expand Down
15 changes: 3 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,10 @@ Key resources for contributors:

> **Note:** All pull requests must follow the [PR Evidence Checklist](docs/pr-evidence-checklist.md) and be audited against the [Evaluation Readiness Dashboard](docs/evaluation-readiness.md) before requesting review.

The transactions page also includes a contributor-ready fixture gallery for transaction review, progress, and receipt states. See [docs/transaction-components.md](docs/transaction-components.md) for the shared component contract and [docs/transaction-review-modal.md](docs/transaction-review-modal.md) for review-before-sign behaviour, risk notes, and the operation summary mapper.
The transactions page also includes a contributor-ready fixture gallery for transaction review, progress, and receipt states. See [docs/transaction-components.md](docs/transaction-components.md) for the shared component contract, fixture expectations, and compliance-safe wording guidance.

## Evaluation Readiness
Before submitting a pull request for maintainer review or payment evaluation, review the [Evaluation Readiness Dashboard](docs/evaluation-readiness.md). It links testing standards, CI status expectations, PR evidence templates, acceptance criteria mapping, and conduct guidelines into a single scorecard.

## Payment-Period Conduct
During GrantFox evaluation windows, all contributors must follow the [Payment-Period Conduct Policy](docs/payment-period-conduct.md). Repeated spam, payout-related complaints, or harassment in community channels will not be tolerated. Self-review your PR thoroughly before raising payment concerns.
## Transfer Restriction Explainer
Investor-facing transfer flows now include a reusable explainer for transfer restrictions. The logic lives in [src/lib/eligibility.ts](src/lib/eligibility.ts) and the UI is surfaced in the investor portfolio and transfer modal. See [docs/investor-transfer-eligibility.md](docs/investor-transfer-eligibility.md) for the typed state model, edge cases, and compliance-safe wording guidance.

## Route Access
Role-aware route guards protect admin, issuer, investor, and read-only sections. See `docs/route-access.md` for route mapping, guard states, SDK assumptions, and mock wallet fixtures.

## Contribution Quality Examples
Before submitting a PR, read the [Contribution Quality Examples](docs/contribution-quality-examples.md) for concrete examples of low-effort, partial, under-tested, failing-CI, and acceptable contributions — including what each category looks like and how to improve it. The companion [Low-Effort PR Examples](docs/low-effort-pr-examples.md) covers additional screenshot and CI-specific anti-patterns.

## Documentation Index
A full index of every file in `docs/` organised by audience (onboarding, building features, review and payment, release) is at [docs/README.md](docs/README.md).
8 changes: 5 additions & 3 deletions docs/investor-transfer-eligibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ This panel surfaces a human-readable explanation instead of a bare error.
- No wording implies a legal or regulatory guarantee.

## Files
- `src/lib/eligibility.ts` — pure evaluation engine (no React; fully unit-tested).
- `src/lib/eligibility.ts` — pure evaluation engine (no React; fully unit-tested) plus the portfolio-facing explainer helper.
- `src/lib/__fixtures__/eligibility.ts` — sample inputs for all major states.
- `src/features/investor/InvestorEligibilityPanel.tsx` — the UI panel.
- `src/features/investor/InvestorEligibilityPanel.tsx` — the reusable panel UI.
- `src/features/investor/components/TransferRestrictionExplainer.tsx` — the portfolio/transfer-modal explainer used in investor flows.
- `src/lib/eligibility.test.ts` — engine unit tests.
- `src/features/investor/InvestorEligibilityPanel.test.tsx` — component tests.
- `src/features/assets/components/AssetCard.test.tsx` — regression coverage for the investor asset card experience.

## SDK mapping
The dashboard should feed real signals into `EligibilityInput`:
Expand All @@ -45,4 +47,4 @@ The dashboard should feed real signals into `EligibilityInput`:
- `serviceAvailable` ← backend/SDK reachability.

Until those signals are wired, the panel can be rendered with `serviceAvailable: false`
to honestly show an `unknown` state rather than guessing.
to honestly show an `unknown` state rather than guessing. For portfolio and transfer modal contexts, the same logic is exposed through `explainPortfolioTransferEligibility(asset)` so contributors can reuse one consistent explanation path rather than branching into ad-hoc copy.
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@ describe('TransactionFixtureGallery', () => {
screen.getByRole('heading', { name: /component fixture gallery/i }),
).toBeInTheDocument();
expect(screen.getByText(/review transfer before signing/i)).toBeInTheDocument();
expect(screen.getByText(/review whitelist update/i)).toBeInTheDocument();
expect(screen.getByText(/waiting for signature/i)).toBeInTheDocument();
// 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();
});
Expand Down
34 changes: 11 additions & 23 deletions src/components/transactions/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ interface TransactionFixtureGalleryEntry {
explorerUrl?: string | null;
}

interface TransactionFixtureGalleryEntry {
id: string;
kind: 'review' | 'progress' | 'receipt';
title: string;
description: string;
details?: TransactionDetails;
state?: 'signing' | 'pending';
result?: TransactionResult;
explorerUrl?: string | null;
}

/**
* Sample data for previewing the transaction components without running a real
* flow — useful for manual QA of the failure and unknown states, which the
Expand Down Expand Up @@ -93,13 +104,6 @@ export const transactionResultFixtures: TransactionResult[] = [
unknownResultFixture,
];

export const transactionReviewFixtures: TransactionDetails[] = [
transferDetailsFixture,
mintDetailsFixture,
whitelistDetailsFixture,
complianceDetailsFixture,
];

export const transactionFixtureGalleryEntries: TransactionFixtureGalleryEntry[] = [
{
id: 'review-transfer',
Expand All @@ -115,13 +119,6 @@ export const transactionFixtureGalleryEntries: TransactionFixtureGalleryEntry[]
description: 'Preview the confirmation copy for an issuance workflow.',
details: mintDetailsFixture,
},
{
id: 'review-whitelist',
kind: 'review',
title: 'Review whitelist update',
description: 'Preview the confirmation screen for a whitelist add action.',
details: whitelistDetailsFixture,
},
{
id: 'review-compliance',
kind: 'review',
Expand Down Expand Up @@ -152,15 +149,6 @@ 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',
Expand Down
38 changes: 38 additions & 0 deletions src/features/assets/components/AssetCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import AssetCard from './AssetCard';
import type { PortfolioAsset } from '@/lib/aegis/types';

const asset: PortfolioAsset = {
id: 'asset-1',
name: 'Example Bond',
ticker: 'BND',
balance: 1250,
decimals: 7,
metadata: {
assetClass: 'Private Credit',
issuer: 'Example Issuer',
jurisdiction: 'US',
description: 'Example bond',
},
compliance: {
state: 'restricted',
label: 'Restricted',
detail: 'This asset has transfer restrictions.',
},
transferEligibility: {
state: 'ineligible',
reasons: ['Restricted to accredited investors only.'],
},
isDataAvailable: true,
};

describe('AssetCard', () => {
it('shows a transfer restriction explainer when the asset is not eligible', () => {
render(<AssetCard asset={asset} onTransferClick={() => {}} />);

expect(screen.getByText(/Transfer eligibility/i)).toBeInTheDocument();
expect(screen.getByText('Not eligible')).toBeInTheDocument();
});
});
8 changes: 6 additions & 2 deletions src/features/assets/components/AssetCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { formatAmount } from '@/utils/formatting';
import ComplianceBadge from './ComplianceBadge';
import TransferEligibilityBadge from './TransferEligibilityBadge';
import AssetLifecycleBadge from './AssetLifecycleBadge';
import TransferRestrictionExplainer from '@/features/investor/components/TransferRestrictionExplainer';
import type { PortfolioAsset } from '@/lib/aegis/types';

interface AssetCardProps {
Expand Down Expand Up @@ -45,10 +45,14 @@ export default function AssetCard({ asset, onTransferClick }: AssetCardProps) {
</p>
</div>

<div className="mb-6">
<div className="mb-4">
<TransferEligibilityBadge eligibility={transferEligibility} />
</div>

<div className="mb-6">
<TransferRestrictionExplainer asset={asset} compact />
</div>

<button
onClick={onTransferClick}
disabled={!canTransfer}
Expand Down
6 changes: 3 additions & 3 deletions src/features/investor/components/TransferModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 TransferRestrictionExplainer from './TransferRestrictionExplainer';
import { getExplorerUrl } from '@/components/transactions/explorerLink';
import { buildTransferSummary } from '@/components/transactions/operationSummary';
import type {
Expand Down Expand Up @@ -190,9 +191,8 @@ export default function TransferModal({ asset, onClose }: TransferModalProps) {
return (
<>
<h2 className="text-xl font-bold mb-4">Transfer {asset.ticker}</h2>
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
This asset is not currently eligible for transfer.
{asset.transferEligibility.reasons[0] ? ` ${asset.transferEligibility.reasons[0]}` : ''}
<div className="mb-4">
<TransferRestrictionExplainer asset={asset} />
</div>
<button
onClick={onClose}
Expand Down
37 changes: 37 additions & 0 deletions src/features/investor/components/TransferRestrictionExplainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { PortfolioAsset } from '@/lib/aegis/types';
import { explainPortfolioTransferEligibility } from '@/lib/eligibility';

const BADGE_STYLES = {
compliant: 'bg-green-50 text-green-700 border-green-200',
blocked: 'bg-red-50 text-red-700 border-red-200',
unknown: 'bg-amber-50 text-amber-700 border-amber-200',
unavailable: 'bg-slate-100 text-slate-600 border-slate-200',
} as const;

interface TransferRestrictionExplainerProps {
asset: PortfolioAsset;
compact?: boolean;
}

export default function TransferRestrictionExplainer({ asset, compact = false }: TransferRestrictionExplainerProps) {
const result = explainPortfolioTransferEligibility(asset);

return (
<div
className={`rounded-lg border border-slate-200 ${compact ? 'bg-slate-50 p-3' : 'bg-white p-4 shadow-sm'}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Transfer eligibility
</div>
<p className="mt-1 text-sm text-slate-600">{result.message}</p>
{result.hint ? <p className="mt-1 text-xs text-slate-400">{result.hint}</p> : null}
</div>
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${BADGE_STYLES[result.state]}`}>
{result.title}
</span>
</div>
</div>
);
}
51 changes: 51 additions & 0 deletions src/lib/eligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,54 @@ export function evaluateOnboardingEligibility(
}

export const STATE_ORDER: EligibilityState[] = ['compliant', 'blocked', 'unknown', 'unavailable'];

export function explainPortfolioTransferEligibility(asset: {
ticker: string;
compliance?: { state?: string; detail?: string };
transferEligibility?: { state?: string; reasons?: string[] };
isDataAvailable?: boolean;
}): EligibilityResult {
const state = asset.transferEligibility?.state;
const reasons = asset.transferEligibility?.reasons ?? [];
const detail = asset.compliance?.detail;

if (!asset.isDataAvailable) {
return {
state: 'unknown',
title: 'Status unknown',
message: 'We could not verify transfer eligibility for this asset right now. This is not a confirmation that the transfer is allowed or blocked.',
hint: 'Try again once the compliance registry sync is available.',
assetSpecific: true,
};
}

if (state === 'ineligible') {
const reason = reasons[0] ?? detail ?? `This asset (${asset.ticker}) has transfer restrictions under current compliance rules.`;
return {
state: 'blocked',
title: 'Not eligible',
message: reason,
hint: 'Complete any required compliance steps before retrying.',
assetSpecific: true,
};
}

if (state === 'unknown') {
return {
state: 'unknown',
title: 'Status unknown',
message: 'We could not verify transfer eligibility right now. This is not a confirmation that the transfer is allowed or blocked.',
hint: 'Check the latest compliance status or try again later.',
assetSpecific: true,
};
}

return {
state: 'compliant',
title: 'Eligible',
message: `Based on the information available, this wallet appears able to transfer ${asset.ticker} at this time. Final approval is decided on-chain at transfer.`,
hint: 'Always confirm the on-chain result before relying on this.',
assetSpecific: true,
};
}

Loading
Loading