Skip to content
Open
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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 29 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvestorPortfolio>`
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<InvestorEligibilityExplanation>`
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`

Expand Down
165 changes: 165 additions & 0 deletions docs/investor-eligibility.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/investor-portfolio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
15 changes: 15 additions & 0 deletions src/errors/eligibility.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
Loading
Loading