diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62afcbb..0628480 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ See [docs/payment-period-conduct.md](docs/payment-period-conduct.md) for the ful ## Updating API Reference Documentation -When you add or change a public method on `ComplianceModule`, `AssetModule`, `InvestorModule`, `EventsModule`, or an exported utility/type, update `docs/api-reference.md` (and `docs/investor-portfolio.md` if the investor read model changes; `docs/contract-events.md` if event decoding changes). Review checklist: +When you add or change a public method on `ComplianceModule`, `AssetModule`, `InvestorModule`, `EventsModule`, or an exported utility/type, update `docs/api-reference.md` (and `docs/investor-portfolio.md` if the investor read model changes; `docs/investor-eligibility.md` if eligibility explanation changes; `docs/contract-events.md` if event decoding changes). Review checklist: - [ ] The signature block matches the method's actual TypeScript signature (parameter names, types, return type). - [ ] The Parameters section lists every parameter, including optional ones and their defaults. @@ -54,4 +54,5 @@ When you add or change a public method on `ComplianceModule`, `AssetModule`, `In - [ ] The example uses only placeholder keys/addresses (`G...`, `C...`, `S...`) — never a real secret key or mainnet contract ID. - [ ] Anything the source leaves ambiguous, incomplete, or marked with a `// TODO` is called out as an explicit note rather than assumed or omitted. - [ ] If the change affects compliance/whitelist-gated behavior, the compliance disclaimer at the top of `docs/api-reference.md` still accurately describes it. +- [ ] If the change affects investor eligibility explanations, follow the checklist in `docs/investor-eligibility.md` (all five statuses, safe messages, and no legal guarantee). - [ ] If the change affects contract event decoding, follow the checklist in `docs/contract-events.md` (edge cases, unknown fallback, and security/compliance assumptions). diff --git a/README.md b/README.md index 0ccdb4d..4ed245e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,19 @@ const capability = await aegis.role.checkCapability('G_USER_PUBLIC_KEY', 'receiv console.log('Can receive transfer?', capability.isPermitted); ``` +## Investor Eligibility Explanation +Turn a whitelist boolean (or revoke signal) into a UI-friendly explanation with a +reason code and suggested next action. This is dashboard UX guidance — not a legal +determination. See [Investor Eligibility Explanation](./docs/investor-eligibility.md). + +```typescript +const explanation = await aegis.investor.explainEligibility('G_USER_PUBLIC_KEY'); +console.log(explanation.status); // 'approved' | 'blocked' | 'revoked' | 'unknown' | 'unavailable' +console.log(explanation.code); // e.g. 'NOT_WHITELISTED' +console.log(explanation.nextAction); // e.g. 'complete-kyc' +console.log(explanation.disclaimer); // always present; no legal guarantee +``` + ## Contract Event Decoder Decode Soroban contract events into typed audit-trail models for dashboards and indexers. diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..0246e3b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -157,11 +157,39 @@ try { ## `InvestorModule` -Read model service for building investor dashboard views. +Read model service for building investor dashboard views. See +[Investor Portfolio Documentation](./investor-portfolio.md) and +[Investor Eligibility Explanation](./investor-eligibility.md). ### Methods * `getPortfolio(investorAddress: string, options?: FetchPortfolioOptions): Promise` Fetches investor balances, KYC whitelist compliance, asset metadata, formatted display balances, transfer eligibility, and operational portfolio status (`active`, `empty`, `blocked`, `unavailable`). +* `explainEligibility(investorAddress: string): Promise` + Runs `ComplianceModule.checkWhitelist` and maps the result into a UI explanation with reason code, safe message, suggested `nextAction`, and a fixed non-guarantee `disclaimer`. A bare whitelist `false` becomes `blocked` (not `revoked`). Invalid addresses and compliance failures become `unavailable` without copying raw RPC errors. `verified` is always `false`. +* `explainEligibilityFromSignals(input): InvestorEligibilityExplanation` + Pure mapping of already-known signals (no RPC). Use when a portfolio/role result is already loaded, or when an off-chain KYC system / admin receipt / whitelist-remove event confirms a revoke via `isKycRevoked: true`. + +### Standalone helpers +* `buildInvestorEligibilityExplanation(input)` — pure mapper behind the module methods. Results are frozen. +* `explainWhitelistResult(isKycApproved, options?)` — convenience for a boolean whitelist result. +* `normalizeInvestorEligibilityStatus(status)` — maps aliases to `approved` | `blocked` | `revoked` | `unknown` | `unavailable`; unrecognised values return `unknown`. +* `ELIGIBILITY_DISCLAIMER` — the fixed non-guarantee notice attached to every explanation. + +**Example** +```typescript +const explanation = await client.investor.explainEligibility('G_INVESTOR'); + +if (explanation.status === 'blocked') { + // Show KYC CTA — do not treat this as a legal determination. + console.log(explanation.nextAction); // 'complete-kyc' +} +console.log(explanation.disclaimer); +``` + +> **Open note:** `checkWhitelist` only returns a boolean, so the live +> `explainEligibility` path cannot emit `revoked` on its own. Callers that learn +> of a revoke from an admin receipt, contract event, or off-chain KYC system +> should pass `isKycRevoked: true` through `explainEligibilityFromSignals`. ## `RoleModule` diff --git a/docs/investor-eligibility.md b/docs/investor-eligibility.md new file mode 100644 index 0000000..e4266d5 --- /dev/null +++ b/docs/investor-eligibility.md @@ -0,0 +1,165 @@ +# Investor eligibility explanation + +Dashboards often need more than a boolean whitelist result. A bare `false` +cannot tell a user whether they were never approved, whether KYC was revoked, +or whether the compliance query simply failed. The eligibility mapper turns +observable SDK signals into a stable UI explanation with a reason code, a safe +message, and a suggested next action. + +## Important: what this is, and is not + +Every explanation is a **dashboard UX convenience**. It is derived from +SDK-observable compliance signals (primarily +`ComplianceModule.checkWhitelist()`), optional revoke hints from other sources, +and address validation. + +It is **not**: + +- legal, financial, or regulatory advice +- a guarantee that a transfer, mint, or other action will succeed +- a substitute for simulating/submitting the actual transaction + +The Aegis Soroban contract remains the final authority. Every +`InvestorEligibilityExplanation` carries a fixed `disclaimer` field so this +non-guarantee language cannot be dropped accidentally when serialising for UI +or support tooling. `verified` is always `false` today for the same reason +capability checks leave it false — the SDK is reporting an observable signal, +not a simulated on-chain guarantee. + +## Status model + +| Status | Meaning | +| ------------- | ----------------------------------------------------------------------- | +| `approved` | Address appears on the protocol whitelist. | +| `blocked` | Address is not on the whitelist (never approved, or unknown why). | +| `revoked` | Previously granted standing appears revoked. | +| `unknown` | Signals were insufficient or unrecognised. Outcome is indeterminate. | +| `unavailable` | Eligibility could not be evaluated (bad address or compliance failure). | + +### `blocked` vs `revoked` + +`ComplianceModule.checkWhitelist()` returns only a boolean today. A bare +`false` therefore maps to **`blocked`**, not `revoked` — the SDK cannot tell +"never approved" from "previously approved then revoked" from that signal +alone. + +Use `revoked` only when another source can confirm a revoke, for example: + +- an admin `whitelist-remove` receipt +- a decoded `whitelist_remove` contract event +- an off-chain KYC / compliance system + +Pass `isKycRevoked: true` (or `status: 'revoked'`) into the mapper in those +cases. + +## Reason codes and next actions + +| Code | Typical status | Suggested `nextAction` | +| ------------------------- | --------------- | -------------------------------- | +| `WHITELISTED` | `approved` | `none` | +| `NOT_WHITELISTED` | `blocked` | `complete-kyc` | +| `KYC_REVOKED` | `revoked` | `contact-compliance` | +| `COMPLIANCE_QUERY_FAILED` | `unavailable` | `retry-with-backoff` | +| `INVALID_ADDRESS` | `unavailable` | `verify-address` | +| `INSUFFICIENT_DATA` | `unknown` | `inspect-compliance-response` | +| `UNRECOGNIZED_STATUS` | `unknown` | `inspect-compliance-response` | + +`nextAction` is a UI CTA hint, not a legal instruction. Dashboards should map +it to their own flows (open KYC wizard, show support contact, retry button). + +## Pure mapper (no RPC) + +```typescript +import { + buildInvestorEligibilityExplanation, + explainWhitelistResult, +} from '@aegis/sdk'; + +const approved = explainWhitelistResult(true, { address: 'G...' }); +// status: 'approved', code: 'WHITELISTED' + +const blocked = explainWhitelistResult(false, { address: 'G...' }); +// status: 'blocked', code: 'NOT_WHITELISTED' — not revoked + +const revoked = buildInvestorEligibilityExplanation({ + address: 'G...', + isKycRevoked: true, +}); +// status: 'revoked', code: 'KYC_REVOKED' +``` + +Mapping priority when multiple signals are present: + +1. `invalidAddress` +2. `complianceQueryFailed` +3. `isKycRevoked` +4. explicit `status` +5. `isKycApproved` +6. otherwise `unknown` / `INSUFFICIENT_DATA` + +## Live compliance integration + +```typescript +const explanation = await client.investor.explainEligibility('G...'); + +switch (explanation.status) { + case 'approved': + showInvestorHome(explanation); + break; + case 'blocked': + showKycPrompt(explanation.nextAction); // 'complete-kyc' + break; + case 'revoked': + showComplianceContact(explanation); + break; + case 'unavailable': + case 'unknown': + showRetryOrSupport(explanation); + break; +} + +// Always surface the disclaimer in support tooling / advanced UI. +console.log(explanation.disclaimer); +``` + +When a portfolio or role result is already loaded, map without another RPC +round trip: + +```typescript +const explanation = client.investor.explainEligibilityFromSignals({ + address: portfolio.investorAddress, + isKycApproved: portfolio.isKycApproved, + // Set only when a revoke is independently confirmed: + // isKycRevoked: true, +}); +``` + +## Dashboard usage guidance + +- Gate **what to show** with `status` / `code` / `nextAction`. Never use the + explanation alone to decide what to **submit** — still simulate/submit through + `AssetModule` and handle rejection. +- Show `message` as user-facing copy. It is fixed and safe; it never includes + raw RPC payloads, URLs, or credentials. +- Keep `disclaimer` visible in support panels, tooltips, or footer copy so the + non-guarantee language travels with the result. +- Treat `verified: false` as intentional. Do not invent a "verified" badge from + this API. +- Prefer `code` over string-matching `message` when branching in UI logic. +- Pair with [role discovery](./role-discovery.md) for capability gating and with + [investor portfolio](./investor-portfolio.md) for holdings context. Eligibility + explains whitelist standing; portfolio status (`active` / `empty` / `blocked`) + explains holdings. + +## Contributor review checklist + +When changing eligibility behaviour: + +- [ ] All five statuses (`approved`, `blocked`, `revoked`, `unknown`, + `unavailable`) remain representable. +- [ ] A bare whitelist `false` still maps to `blocked`, not `revoked`. +- [ ] Unrecognised statuses resolve to `unknown`, never `approved`. +- [ ] Messages stay fixed and safe — no raw RPC/error interpolation. +- [ ] Every result still includes `disclaimer` and `verified: false`. +- [ ] Docs continue to state that no legal guarantee is implied. +- [ ] Tests cover approved, blocked, revoked, unknown, and unavailable. diff --git a/docs/investor-portfolio.md b/docs/investor-portfolio.md index 308740f..a3ea298 100644 --- a/docs/investor-portfolio.md +++ b/docs/investor-portfolio.md @@ -2,6 +2,8 @@ The `InvestorModule` provides a consolidated, typed read model (`InvestorPortfolio`) designed for investor dashboards, mobile wallets, and compliance monitoring screens. It aggregates asset balances, compliance whitelist status, asset metadata, formatted display amounts, and transfer eligibility into a single unified data structure. +For UI-friendly explanations of *why* an investor is approved, blocked, revoked, unknown, or unavailable — including reason codes and suggested next actions — see [Investor Eligibility Explanation](./investor-eligibility.md). Eligibility explanations are dashboard UX signals and do not imply a legal or regulatory guarantee. + ## Accessing the Portfolio Module Access `investor` via an initialized `AegisClient`: diff --git a/src/errors/eligibility.ts b/src/errors/eligibility.ts new file mode 100644 index 0000000..fc41966 --- /dev/null +++ b/src/errors/eligibility.ts @@ -0,0 +1,15 @@ +export type EligibilityExplanationErrorCode = + | 'INVALID_ADDRESS' + | 'INVALID_TIMESTAMP' + | 'INVALID_INPUT'; + +export class EligibilityExplanationError extends Error { + public readonly code: EligibilityExplanationErrorCode; + + constructor(code: EligibilityExplanationErrorCode, message: string) { + super(message); + this.name = 'EligibilityExplanationError'; + this.code = code; + Object.setPrototypeOf(this, EligibilityExplanationError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index b498dcf..f87b06c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,12 @@ export * from './errors/client-factory'; export { ComplianceModule } from './compliance'; export { AssetModule } from './asset'; export { InvestorModule } from './investor/portfolio'; +export { + buildInvestorEligibilityExplanation, + explainWhitelistResult, + normalizeInvestorEligibilityStatus, + ELIGIBILITY_DISCLAIMER, +} from './investor/eligibility'; export { RoleModule } from './role'; export { EventsModule } from './events/module'; export { decodeContractEvent, decodeContractEvents } from './events/decoder'; @@ -44,6 +50,8 @@ export { resolveClientConfig } from './config/validate'; export { AEGIS_ENVIRONMENTS, getEnvironmentPreset } from './config/environments'; export * from './types/portfolio'; export * from './errors/portfolio'; +export * from './types/eligibility'; +export * from './errors/eligibility'; export * from './types/role'; export * from './errors/role'; export * from './types/admin-receipt'; diff --git a/src/investor/eligibility.ts b/src/investor/eligibility.ts new file mode 100644 index 0000000..74b9665 --- /dev/null +++ b/src/investor/eligibility.ts @@ -0,0 +1,207 @@ +import { EligibilityExplanationError } from '../errors/eligibility'; +import { + InvestorEligibilityExplanation, + InvestorEligibilityExplanationInput, + InvestorEligibilityNextAction, + InvestorEligibilityReasonCode, + InvestorEligibilityStatus, +} from '../types/eligibility'; + +export const ELIGIBILITY_DISCLAIMER = + 'This explanation is a dashboard UX signal derived from SDK-observable ' + + 'compliance data. It is not legal, financial, or regulatory advice, and it ' + + 'does not guarantee that a transaction will succeed. The Aegis Soroban ' + + 'contract remains the final authority.'; + +interface EligibilityMapping { + status: InvestorEligibilityStatus; + code: InvestorEligibilityReasonCode; + message: string; + nextAction: InvestorEligibilityNextAction; + isEligible: boolean; + isKycApproved: boolean; +} + +const STATUS_ALIASES: Readonly> = { + APPROVED: 'approved', + ELIGIBLE: 'approved', + WHITELISTED: 'approved', + SUCCESS: 'approved', + BLOCKED: 'blocked', + DENIED: 'blocked', + NOT_WHITELISTED: 'blocked', + UNAUTHORIZED: 'blocked', + REVOKED: 'revoked', + KYC_REVOKED: 'revoked', + UNKNOWN: 'unknown', + UNAVAILABLE: 'unavailable', +}; + +const STATUS_MAPPINGS: Readonly< + Record +> = { + approved: { + status: 'approved', + code: 'WHITELISTED', + message: + 'Address appears on the protocol whitelist and is currently treated as eligible for whitelist-gated actions.', + nextAction: 'none', + isEligible: true, + isKycApproved: true, + }, + blocked: { + status: 'blocked', + code: 'NOT_WHITELISTED', + message: + 'Address is not on the protocol whitelist, so whitelist-gated actions are expected to be blocked.', + nextAction: 'complete-kyc', + isEligible: false, + isKycApproved: false, + }, + revoked: { + status: 'revoked', + code: 'KYC_REVOKED', + message: + 'Previously granted whitelist standing appears to have been revoked. Whitelist-gated actions are expected to be blocked.', + nextAction: 'contact-compliance', + isEligible: false, + isKycApproved: false, + }, + unknown: { + status: 'unknown', + code: 'INSUFFICIENT_DATA', + message: + 'Eligibility could not be determined from the available signals. Treat the outcome as indeterminate.', + nextAction: 'inspect-compliance-response', + isEligible: false, + isKycApproved: false, + }, + unavailable: { + status: 'unavailable', + code: 'COMPLIANCE_QUERY_FAILED', + message: + 'Eligibility could not be evaluated because the compliance signal is unavailable.', + nextAction: 'retry-with-backoff', + isEligible: false, + isKycApproved: false, + }, +}; + +/** + * Normalises a raw status string into a stable eligibility status. + * Unrecognised values resolve to `unknown` rather than implying approval. + */ +export function normalizeInvestorEligibilityStatus( + status: string, +): InvestorEligibilityStatus { + const normalized = status.trim().toUpperCase(); + return STATUS_ALIASES[normalized] ?? 'unknown'; +} + +/** + * Builds a UI-friendly eligibility explanation from observable compliance signals. + * + * Messages are fixed, safe strings — raw RPC errors, URLs, and credentials are + * never copied. Every result includes {@link ELIGIBILITY_DISCLAIMER}. + */ +export function buildInvestorEligibilityExplanation( + input: InvestorEligibilityExplanationInput = {}, +): InvestorEligibilityExplanation { + const mapping = resolveMapping(input); + const address = + typeof input.address === 'string' ? input.address.trim() : ''; + + return Object.freeze({ + address, + status: mapping.status, + code: mapping.code, + message: mapping.message, + nextAction: mapping.nextAction, + isEligible: mapping.isEligible, + isKycApproved: mapping.isKycApproved, + verified: false, + disclaimer: ELIGIBILITY_DISCLAIMER, + evaluatedAt: normalizeEvaluatedAt(input.evaluatedAt), + }); +} + +/** + * Convenience: map a boolean whitelist result into an explanation. + * + * A bare `false` becomes `blocked`, not `revoked`. Pass `isKycRevoked: true` + * through {@link buildInvestorEligibilityExplanation} when revoke is known. + */ +export function explainWhitelistResult( + isKycApproved: boolean, + options: Omit = {}, +): InvestorEligibilityExplanation { + return buildInvestorEligibilityExplanation({ + ...options, + isKycApproved, + }); +} + +function resolveMapping( + input: InvestorEligibilityExplanationInput, +): EligibilityMapping { + if (input.invalidAddress === true) { + return { + status: 'unavailable', + code: 'INVALID_ADDRESS', + message: + 'Eligibility could not be evaluated because the address is missing or invalid.', + nextAction: 'verify-address', + isEligible: false, + isKycApproved: false, + }; + } + + if (input.complianceQueryFailed === true) { + return { + ...STATUS_MAPPINGS.unavailable, + code: 'COMPLIANCE_QUERY_FAILED', + nextAction: 'retry-with-backoff', + }; + } + + if (input.isKycRevoked === true) { + return STATUS_MAPPINGS.revoked; + } + + if (typeof input.status === 'string' && input.status.trim()) { + const status = normalizeInvestorEligibilityStatus(input.status); + if (status === 'unknown' && !STATUS_ALIASES[input.status.trim().toUpperCase()]) { + return { + ...STATUS_MAPPINGS.unknown, + code: 'UNRECOGNIZED_STATUS', + message: + 'Reported eligibility status is not recognised by this SDK version and is treated as indeterminate.', + }; + } + return STATUS_MAPPINGS[status]; + } + + if (typeof input.isKycApproved === 'boolean') { + return input.isKycApproved + ? STATUS_MAPPINGS.approved + : STATUS_MAPPINGS.blocked; + } + + return STATUS_MAPPINGS.unknown; +} + +function normalizeEvaluatedAt(evaluatedAt?: Date | string): string { + const date = + evaluatedAt instanceof Date + ? evaluatedAt + : new Date(evaluatedAt ?? Date.now()); + + if (Number.isNaN(date.getTime())) { + throw new EligibilityExplanationError( + 'INVALID_TIMESTAMP', + 'Evaluation timestamp must be a valid date.', + ); + } + + return date.toISOString(); +} diff --git a/src/investor/portfolio.ts b/src/investor/portfolio.ts index 7574b78..f8acf71 100644 --- a/src/investor/portfolio.ts +++ b/src/investor/portfolio.ts @@ -8,8 +8,13 @@ import { FetchPortfolioOptions, TransferEligibility, } from '../types/portfolio'; +import { + InvestorEligibilityExplanation, + InvestorEligibilityExplanationInput, +} from '../types/eligibility'; import { PortfolioError } from '../errors/portfolio'; import { parseSorobanResult } from '../utils/xdr-parser'; +import { buildInvestorEligibilityExplanation } from './eligibility'; /** * Module for querying and processing investor portfolio read models. @@ -21,6 +26,53 @@ export class InvestorModule { this.client = client; } + /** + * Explains investor eligibility from the live compliance whitelist check. + * + * Returns a frozen UI explanation with a reason code, safe message, and + * suggested next action. Does not imply a legal or regulatory guarantee — + * see `explanation.disclaimer`. A bare whitelist `false` maps to `blocked`; + * pass `isKycRevoked` through {@link explainEligibilityFromSignals} when a + * revoke is known from another source. + */ + public async explainEligibility( + investorAddress: string, + ): Promise { + if (!investorAddress || typeof investorAddress !== 'string') { + return buildInvestorEligibilityExplanation({ + address: investorAddress || '', + invalidAddress: true, + }); + } + + try { + const isKycApproved = + await this.client.compliance.checkWhitelist(investorAddress); + return buildInvestorEligibilityExplanation({ + address: investorAddress, + isKycApproved, + }); + } catch { + // Raw RPC/network messages are intentionally omitted so secrets and + // provider payloads never reach dashboard copy. + return buildInvestorEligibilityExplanation({ + address: investorAddress, + complianceQueryFailed: true, + }); + } + } + + /** + * Maps already-known compliance signals into an eligibility explanation + * without contacting RPC. Useful when a portfolio or role result is already + * in hand, or when an off-chain KYC system reports a revoke. + */ + public explainEligibilityFromSignals( + input: InvestorEligibilityExplanationInput, + ): InvestorEligibilityExplanation { + return buildInvestorEligibilityExplanation(input); + } + /** * Fetches the complete portfolio read model for a given investor address. * diff --git a/src/types/eligibility.ts b/src/types/eligibility.ts new file mode 100644 index 0000000..007c219 --- /dev/null +++ b/src/types/eligibility.ts @@ -0,0 +1,102 @@ +/** + * UI-facing investor eligibility standing. + * + * Distinct from `PortfolioStatus` (`active` / `empty` / …), which describes + * portfolio holdings. These statuses explain *why* an investor can or cannot + * participate from a compliance/whitelist perspective. + * + * IMPORTANT: Every explanation produced from these statuses is a dashboard UX + * convenience derived from observable SDK signals. It is not legal, financial, + * or regulatory advice, and it does not guarantee that a transaction will succeed. + * The Aegis Soroban contract remains the final authority. + */ +export type InvestorEligibilityStatus = + | 'approved' + | 'blocked' + | 'revoked' + | 'unknown' + | 'unavailable'; + +/** + * Stable reason codes for eligibility explanations. + * + * Prefer these over free-text matching in dashboards. Unknown future values from + * callers are normalised to `UNRECOGNIZED_STATUS` rather than implying approval. + */ +export type InvestorEligibilityReasonCode = + | 'WHITELISTED' + | 'NOT_WHITELISTED' + | 'KYC_REVOKED' + | 'COMPLIANCE_QUERY_FAILED' + | 'INVALID_ADDRESS' + | 'INSUFFICIENT_DATA' + | 'UNRECOGNIZED_STATUS'; + +/** + * Suggested next UI action. Never a legal instruction — dashboards use this to + * choose a CTA (retry, open KYC flow, contact support), not to advise the user + * on regulatory obligations. + */ +export type InvestorEligibilityNextAction = + | 'none' + | 'complete-kyc' + | 'contact-compliance' + | 'retry' + | 'retry-with-backoff' + | 'verify-address' + | 'inspect-compliance-response'; + +/** + * Inputs the mapper accepts. Prefer the most specific signal available. + * + * Priority when multiple signals are present: + * 1. `invalidAddress` + * 2. `complianceQueryFailed` + * 3. `isKycRevoked` + * 4. explicit `status` + * 5. `isKycApproved` + * 6. otherwise `unknown` / `INSUFFICIENT_DATA` + * + * `isKycRevoked` exists because `ComplianceModule.checkWhitelist()` only returns + * a boolean today — it cannot distinguish "never approved" from "previously + * approved then revoked". Callers that learn of a revoke from an admin receipt, + * event, or off-chain KYC system can set this flag so the mapper emits `revoked` + * instead of the more generic `blocked`. + */ +export interface InvestorEligibilityExplanationInput { + address?: string; + status?: InvestorEligibilityStatus | string; + isKycApproved?: boolean; + isKycRevoked?: boolean; + complianceQueryFailed?: boolean; + invalidAddress?: boolean; + evaluatedAt?: Date | string; +} + +/** + * UI-friendly eligibility explanation. + * + * Results are frozen. `disclaimer` is always present so consumers cannot omit + * the non-guarantee language when serialising for support tickets or dashboards. + */ +export interface InvestorEligibilityExplanation { + address: string; + status: InvestorEligibilityStatus; + code: InvestorEligibilityReasonCode; + /** Safe, user-facing summary. Contains no secrets or legal guarantees. */ + message: string; + nextAction: InvestorEligibilityNextAction; + isEligible: boolean; + isKycApproved: boolean; + /** + * Always `false` today. Whitelist checks are observable signals, not a + * simulated guarantee that a transfer or mint will succeed on-chain. + */ + verified: boolean; + /** + * Fixed non-guarantee notice. Always present on every explanation so + * dashboards and support tooling cannot drop the disclaimer by accident. + */ + disclaimer: string; + evaluatedAt: string; +} diff --git a/tests/investor-eligibility.test.ts b/tests/investor-eligibility.test.ts new file mode 100644 index 0000000..155e064 --- /dev/null +++ b/tests/investor-eligibility.test.ts @@ -0,0 +1,280 @@ +import { Networks } from '@stellar/stellar-sdk'; +import { + AegisClient, + EligibilityExplanationError, + ELIGIBILITY_DISCLAIMER, + buildInvestorEligibilityExplanation, + explainWhitelistResult, + normalizeInvestorEligibilityStatus, +} from '../src'; + +describe('normalizeInvestorEligibilityStatus', () => { + it.each([ + ['approved', 'approved'], + ['WHITELISTED', 'approved'], + ['eligible', 'approved'], + ['blocked', 'blocked'], + ['NOT_WHITELISTED', 'blocked'], + ['revoked', 'revoked'], + ['KYC_REVOKED', 'revoked'], + ['unknown', 'unknown'], + ['unavailable', 'unavailable'], + ] as const)('maps %s to %s', (input, expected) => { + expect(normalizeInvestorEligibilityStatus(input)).toBe(expected); + }); + + it('treats unrecognised future statuses as unknown rather than approved', () => { + expect(normalizeInvestorEligibilityStatus('PARTIALLY_APPROVED')).toBe( + 'unknown', + ); + }); +}); + +describe('buildInvestorEligibilityExplanation', () => { + it('maps an approved whitelist signal', () => { + const result = buildInvestorEligibilityExplanation({ + address: 'G_APPROVED', + isKycApproved: true, + evaluatedAt: '2026-07-29T00:00:00.000Z', + }); + + expect(result).toMatchObject({ + address: 'G_APPROVED', + status: 'approved', + code: 'WHITELISTED', + nextAction: 'none', + isEligible: true, + isKycApproved: true, + verified: false, + evaluatedAt: '2026-07-29T00:00:00.000Z', + }); + expect(result.message).toContain('whitelist'); + expect(result.disclaimer).toBe(ELIGIBILITY_DISCLAIMER); + }); + + it('maps a bare false whitelist result to blocked, not revoked', () => { + const result = explainWhitelistResult(false, { address: 'G_BLOCKED' }); + + expect(result.status).toBe('blocked'); + expect(result.code).toBe('NOT_WHITELISTED'); + expect(result.nextAction).toBe('complete-kyc'); + expect(result.isEligible).toBe(false); + }); + + it('maps an explicit revoke signal to revoked', () => { + const result = buildInvestorEligibilityExplanation({ + address: 'G_REVOKED', + isKycApproved: false, + isKycRevoked: true, + }); + + expect(result).toMatchObject({ + status: 'revoked', + code: 'KYC_REVOKED', + nextAction: 'contact-compliance', + isEligible: false, + isKycApproved: false, + }); + }); + + it('prefers revoke over a conflicting approved boolean', () => { + const result = buildInvestorEligibilityExplanation({ + isKycApproved: true, + isKycRevoked: true, + }); + + expect(result.status).toBe('revoked'); + }); + + it('maps compliance query failure to unavailable with a safe message', () => { + const result = buildInvestorEligibilityExplanation({ + address: 'G_USER', + complianceQueryFailed: true, + }); + + expect(result).toMatchObject({ + status: 'unavailable', + code: 'COMPLIANCE_QUERY_FAILED', + nextAction: 'retry-with-backoff', + isEligible: false, + }); + expect(result.message).not.toContain('secret'); + expect(result.message).not.toContain('http'); + }); + + it('maps an invalid address to unavailable', () => { + const result = buildInvestorEligibilityExplanation({ + address: '', + invalidAddress: true, + }); + + expect(result).toMatchObject({ + status: 'unavailable', + code: 'INVALID_ADDRESS', + nextAction: 'verify-address', + }); + }); + + it('maps insufficient signals to unknown', () => { + const result = buildInvestorEligibilityExplanation({ address: 'G_USER' }); + + expect(result).toMatchObject({ + status: 'unknown', + code: 'INSUFFICIENT_DATA', + nextAction: 'inspect-compliance-response', + isEligible: false, + }); + }); + + it('maps an unrecognised explicit status to unknown', () => { + const result = buildInvestorEligibilityExplanation({ + status: 'PARTIALLY_APPROVED', + }); + + expect(result.status).toBe('unknown'); + expect(result.code).toBe('UNRECOGNIZED_STATUS'); + }); + + it('accepts explicit status aliases including revoked', () => { + expect( + buildInvestorEligibilityExplanation({ status: 'KYC_REVOKED' }).status, + ).toBe('revoked'); + expect( + buildInvestorEligibilityExplanation({ status: 'approved' }).status, + ).toBe('approved'); + }); + + it('never implies a legal guarantee in the message or disclaimer', () => { + const statuses = [ + { isKycApproved: true }, + { isKycApproved: false }, + { isKycRevoked: true }, + { complianceQueryFailed: true }, + { invalidAddress: true }, + {}, + ] as const; + + for (const input of statuses) { + const result = buildInvestorEligibilityExplanation(input); + const blob = `${result.message} ${result.disclaimer}`.toLowerCase(); + + expect(blob).toContain('not legal'); + expect(blob).not.toContain('guarantees approval'); + expect(blob).not.toContain('legally eligible'); + expect(result.verified).toBe(false); + expect(result.disclaimer).toBe(ELIGIBILITY_DISCLAIMER); + } + }); + + it('returns a frozen result safe for dashboard caching', () => { + const result = buildInvestorEligibilityExplanation({ isKycApproved: true }); + expect(Object.isFrozen(result)).toBe(true); + }); + + it('rejects an invalid evaluation timestamp', () => { + expect(() => + buildInvestorEligibilityExplanation({ + isKycApproved: true, + evaluatedAt: 'not-a-date', + }), + ).toThrow(EligibilityExplanationError); + expect(() => + buildInvestorEligibilityExplanation({ + isKycApproved: true, + evaluatedAt: 'not-a-date', + }), + ).toThrow(expect.objectContaining({ code: 'INVALID_TIMESTAMP' })); + }); + + it('omits raw provider payloads from serialised output', () => { + const result = buildInvestorEligibilityExplanation({ + address: 'G_USER', + complianceQueryFailed: true, + }); + + expect(JSON.stringify(result)).not.toContain('Bearer'); + expect(JSON.stringify(result)).not.toContain('authorization'); + expect(Object.keys(result)).toEqual([ + 'address', + 'status', + 'code', + 'message', + 'nextAction', + 'isEligible', + 'isKycApproved', + 'verified', + 'disclaimer', + 'evaluatedAt', + ]); + }); +}); + +describe('InvestorModule.explainEligibility', () => { + let client: AegisClient; + + beforeEach(() => { + client = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: 'C...', + }); + }); + + it('explains an approved investor from the live whitelist check', async () => { + jest + .spyOn(client.compliance, 'checkWhitelist') + .mockResolvedValue(true); + + const result = await client.investor.explainEligibility('G_APPROVED'); + + expect(result.status).toBe('approved'); + expect(result.code).toBe('WHITELISTED'); + expect(result.address).toBe('G_APPROVED'); + }); + + it('explains a blocked investor without promoting false to revoked', async () => { + jest + .spyOn(client.compliance, 'checkWhitelist') + .mockResolvedValue(false); + + const result = await client.investor.explainEligibility('G_BLOCKED'); + + expect(result.status).toBe('blocked'); + expect(result.code).toBe('NOT_WHITELISTED'); + }); + + it('explains compliance failures as unavailable without copying raw errors', async () => { + jest.spyOn(client.compliance, 'checkWhitelist').mockRejectedValue( + Object.assign(new Error('https://rpc.example/?token=secret-value timed out'), { + code: 'ETIMEDOUT', + }), + ); + + const result = await client.investor.explainEligibility('G_USER'); + + expect(result.status).toBe('unavailable'); + expect(result.code).toBe('COMPLIANCE_QUERY_FAILED'); + expect(JSON.stringify(result)).not.toContain('secret-value'); + expect(result.message).not.toContain('http'); + }); + + it('explains an invalid address without contacting compliance', async () => { + const spy = jest.spyOn(client.compliance, 'checkWhitelist'); + + const result = await client.investor.explainEligibility(''); + + expect(result.status).toBe('unavailable'); + expect(result.code).toBe('INVALID_ADDRESS'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('maps offline revoke signals without another RPC round trip', () => { + const result = client.investor.explainEligibilityFromSignals({ + address: 'G_REVOKED', + isKycRevoked: true, + }); + + expect(result.status).toBe('revoked'); + expect(result.nextAction).toBe('contact-compliance'); + }); +});