diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bb73a2b..00b1b19 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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:** - --- ## Screenshots / Recordings diff --git a/README.md b/README.md index 4403fee..da1bd04 100644 --- a/README.md +++ b/README.md @@ -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). \ No newline at end of file diff --git a/docs/investor-transfer-eligibility.md b/docs/investor-transfer-eligibility.md index 6d68492..158b9e1 100644 --- a/docs/investor-transfer-eligibility.md +++ b/docs/investor-transfer-eligibility.md @@ -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`: @@ -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. diff --git a/src/components/transactions/TransactionFixtureGallery.test.tsx b/src/components/transactions/TransactionFixtureGallery.test.tsx index a5abe52..317204b 100644 --- a/src/components/transactions/TransactionFixtureGallery.test.tsx +++ b/src/components/transactions/TransactionFixtureGallery.test.tsx @@ -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(); }); diff --git a/src/components/transactions/fixtures.ts b/src/components/transactions/fixtures.ts index cfa5df6..45750a3 100644 --- a/src/components/transactions/fixtures.ts +++ b/src/components/transactions/fixtures.ts @@ -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 @@ -93,13 +104,6 @@ export const transactionResultFixtures: TransactionResult[] = [ unknownResultFixture, ]; -export const transactionReviewFixtures: TransactionDetails[] = [ - transferDetailsFixture, - mintDetailsFixture, - whitelistDetailsFixture, - complianceDetailsFixture, -]; - export const transactionFixtureGalleryEntries: TransactionFixtureGalleryEntry[] = [ { id: 'review-transfer', @@ -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', @@ -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', diff --git a/src/features/assets/components/AssetCard.test.tsx b/src/features/assets/components/AssetCard.test.tsx new file mode 100644 index 0000000..8bec588 --- /dev/null +++ b/src/features/assets/components/AssetCard.test.tsx @@ -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( {}} />); + + expect(screen.getByText(/Transfer eligibility/i)).toBeInTheDocument(); + expect(screen.getByText('Not eligible')).toBeInTheDocument(); + }); +}); diff --git a/src/features/assets/components/AssetCard.tsx b/src/features/assets/components/AssetCard.tsx index 4983d35..c7a2412 100644 --- a/src/features/assets/components/AssetCard.tsx +++ b/src/features/assets/components/AssetCard.tsx @@ -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 { @@ -45,10 +45,14 @@ export default function AssetCard({ asset, onTransferClick }: AssetCardProps) {

-
+
+
+ +
+
+ ); +} diff --git a/src/lib/eligibility.ts b/src/lib/eligibility.ts index e350045..0a9e6a3 100644 --- a/src/lib/eligibility.ts +++ b/src/lib/eligibility.ts @@ -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, + }; +} + diff --git a/vitest-results.json b/vitest-results.json new file mode 100644 index 0000000..ecd4155 --- /dev/null +++ b/vitest-results.json @@ -0,0 +1 @@ +{"numTotalTestSuites":11,"numPassedTestSuites":11,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":21,"numPassedTests":21,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1785515957173,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["evaluateEligibility — compliant"],"fullName":"evaluateEligibility — compliant returns compliant for a fully-verified send","status":"passed","title":"returns compliant for a fully-verified send","duration":9.319899999998597,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — compliant"],"fullName":"evaluateEligibility — compliant returns compliant for a fully-verified receive","status":"passed","title":"returns compliant for a fully-verified receive","duration":0.9243999999998778,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — compliant"],"fullName":"evaluateEligibility — compliant is direction-aware in copy but same state for both directions","status":"passed","title":"is direction-aware in copy but same state for both directions","duration":1.795300000001589,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — blocked"],"fullName":"evaluateEligibility — blocked blocks when wallet KYC is not compliant","status":"passed","title":"blocks when wallet KYC is not compliant","duration":6.993800000000192,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — blocked"],"fullName":"evaluateEligibility — blocked blocks when wallet is on an unsupported network","status":"passed","title":"blocks when wallet is on an unsupported network","duration":1.920799999999872,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — blocked"],"fullName":"evaluateEligibility — blocked blocks on asset-specific restriction and flags assetSpecific","status":"passed","title":"blocks on asset-specific restriction and flags assetSpecific","duration":1.2259000000012747,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — unavailable"],"fullName":"evaluateEligibility — unavailable marks unavailable when asset is paused (never claims blocked/compliant)","status":"passed","title":"marks unavailable when asset is paused (never claims blocked/compliant)","duration":0.856500000001688,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — unknown (no overclaiming)"],"fullName":"evaluateEligibility — unknown (no overclaiming) returns unknown when the service is unavailable","status":"passed","title":"returns unknown when the service is unavailable","duration":0.8086999999977706,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — unknown (no overclaiming)"],"fullName":"evaluateEligibility — unknown (no overclaiming) returns unknown when compliance is partially known","status":"passed","title":"returns unknown when compliance is partially known","duration":0.8038999999989755,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — unknown (no overclaiming)"],"fullName":"evaluateEligibility — unknown (no overclaiming) unknown copy explicitly avoids confirming allow/block","status":"passed","title":"unknown copy explicitly avoids confirming allow/block","duration":1.7767999999996391,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — precedence"],"fullName":"evaluateEligibility — precedence service-down wins over everything (fail-closed)","status":"passed","title":"service-down wins over everything (fail-closed)","duration":0.7389000000002852,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — precedence"],"fullName":"evaluateEligibility — precedence asset pause beats wallet/network issues","status":"passed","title":"asset pause beats wallet/network issues","duration":0.6016999999992549,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["evaluateEligibility — precedence"],"fullName":"evaluateEligibility — precedence network block beats asset/wallet compliance block","status":"passed","title":"network block beats asset/wallet compliance block","duration":0.9611000000004424,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["fixtures coverage"],"fullName":"fixtures coverage covers all four states across the fixture set","status":"passed","title":"covers all four states across the fixture set","duration":3.4483000000000175,"failureMessages":[],"meta":{},"tags":[]}],"startTime":1785515979140,"endTime":1785515979175.4482,"status":"passed","message":"","name":"C:/Users/user/aegis-dashboard/src/lib/eligibility.test.ts"},{"assertionResults":[{"ancestorTitles":["InvestorEligibilityPanel"],"fullName":"InvestorEligibilityPanel renders both send and receive rows","status":"passed","title":"renders both send and receive rows","duration":450.34210000000166,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["InvestorEligibilityPanel"],"fullName":"InvestorEligibilityPanel shows \"Eligible\" badges when compliant","status":"passed","title":"shows \"Eligible\" badges when compliant","duration":29.237700000001496,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["InvestorEligibilityPanel"],"fullName":"InvestorEligibilityPanel shows blocked state with asset-specific reason","status":"passed","title":"shows blocked state with asset-specific reason","duration":32.95550000000003,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["InvestorEligibilityPanel"],"fullName":"InvestorEligibilityPanel shows unavailable when asset is paused","status":"passed","title":"shows unavailable when asset is paused","duration":20.40390000000116,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["InvestorEligibilityPanel"],"fullName":"InvestorEligibilityPanel shows unknown and avoids overclaiming when service is down","status":"passed","title":"shows unknown and avoids overclaiming when service is down","duration":25.103399999999965,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["InvestorEligibilityPanel"],"fullName":"InvestorEligibilityPanel includes the asset ticker in the header when provided","status":"passed","title":"includes the asset ticker in the header when provided","duration":18.86750000000029,"failureMessages":[],"meta":{},"tags":[]}],"startTime":1785515979491,"endTime":1785515980068.8674,"status":"passed","message":"","name":"C:/Users/user/aegis-dashboard/src/features/investor/InvestorEligibilityPanel.test.tsx"},{"assertionResults":[{"ancestorTitles":["AssetCard"],"fullName":"AssetCard shows a transfer restriction explainer when the asset is not eligible","status":"passed","title":"shows a transfer restriction explainer when the asset is not eligible","duration":303.64180000000124,"failureMessages":[],"meta":{},"tags":[]}],"startTime":1785515980115,"endTime":1785515980418.6418,"status":"passed","message":"","name":"C:/Users/user/aegis-dashboard/src/features/assets/components/AssetCard.test.tsx"}]} \ No newline at end of file