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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@ const capability = await aegis.role.checkCapability('G_USER_PUBLIC_KEY', 'receiv
console.log('Can receive transfer?', capability.isPermitted);
```

## Compliance Readiness
Use the typed readiness model when dashboard UX needs more than a whitelist boolean.
Only `approved` is eligible; blocked, revoked, pending, unknown, and unavailable states
stay distinct so the UI can explain or retry them safely.

```typescript
const readiness = await aegis.compliance.checkReadiness('G_USER_PUBLIC_KEY');
if (readiness.state === 'approved') {
// Enable the restricted-action UX; the contract still authorizes the real operation.
}
```

See [Compliance Readiness](./docs/compliance-readiness.md) for state semantics,
stable reason codes, retry guidance, dashboard integration, and the boundary between
protocol-visible status and off-chain legal/KYC determinations.

## Contract Event Decoder
Decode Soroban contract events into typed audit-trail models for dashboards and indexers.

Expand Down
47 changes: 42 additions & 5 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ public async checkWhitelist(address: string): Promise<boolean>
* `address` (string): The Stellar public key (`G...`) to check.

**Returns**
`Promise<boolean>` — `true` if the simulated call to `is_whitelisted` succeeds and decodes to `true`. Resolves to `false` both when the contract reports the address is not whitelisted, *and* when the simulation does not succeed or returns no result — the current implementation does not distinguish those two cases in its return value.
`Promise<boolean>` — `true` if the simulated call to `is_whitelisted` succeeds and its parsed `ScVal` decodes to boolean `true`. Resolves to `false` when the contract reports the address is not whitelisted, when simulation does not succeed or returns no result, or when ledger restoration is still required. This method does not distinguish those cases in its return value.

**Errors**
* If `simulateTransaction` itself throws (network failure, malformed request, etc.), the error is logged via `console.error` and then re-thrown as-is. It is the raw error from the underlying `@stellar/stellar-sdk` RPC call — `checkWhitelist` does not wrap it in `PortfolioError` or any other typed error.
* Source-account lookup, transaction construction, and RPC failures inside `runNetworkOperation` reject with a classified `NetworkFailure`. Invalid contract/address arguments or return-value decoding can also throw. The method does not log errors or wrap them in `PortfolioError`.
* A failed/unsuccessful simulation that does *not* throw is swallowed and reported as `false` (see Returns above), not as an error.

**Example**
Expand All @@ -63,12 +63,48 @@ try {
const isWhitelisted = await client.compliance.checkWhitelist('G_USER_PUBLIC_KEY');
console.log('Is User Whitelisted?', isWhitelisted);
} catch (error) {
// Raised on RPC/network failure during simulation — not a PortfolioError.
// Account lookup or simulation can fail; this is not a PortfolioError.
console.error('Whitelist check failed:', error);
}
```

> **Open note:** `checkWhitelist` passes the raw invocation object returned by `contract.call(...)` directly as the `transaction` field to `simulateTransaction` (cast through `as any`), rather than assembling a full `Transaction` via `TransactionBuilder` the way `AssetModule.mint`/`transfer` do. The source itself flags this with a comment ("Cast required depending on SDK version wrapper"), so the exact request shape expected by `simulateTransaction` across `@stellar/stellar-sdk` versions is not fully confirmed — verify against the installed SDK version rather than assuming it's stable.
Both compliance methods build an unsigned `Transaction` containing the read-only
`is_whitelisted` invocation, using `BASE_FEE`, the client's network passphrase,
and a 30-second timeout. The source account is fetched from the configured
keypair's public key, or from the checked address when no keypair is configured.
That source must exist on the selected network. A missing/unfunded source causes
`checkReadiness` to return `unavailable` and `checkWhitelist` to reject. Neither
method signs or submits a transaction; the configured keypair supplies only its
public key for the read.

### `checkReadiness(address: string): Promise<ComplianceReadinessResult>`

**Parameters**
* `address` (string): A Stellar Ed25519 public key (`G...`).

**Returns**
A typed result containing `state`, `eligible`, `verified`, `code`, `reason`,
`address`, and `checkedAt`. Successful boolean reads map to `approved` or
`blocked`; unexpected return values map to verified `unknown`. Unsuccessful,
result-less, restoration-required, or throwing reads return `unavailable` with
`verified: false`. Invalid addresses return unverified `unknown` with
`INVALID_ADDRESS` without calling RPC. Only `approved` has `eligible: true`.

**Errors**
RPC and decode failures are represented by `unavailable`; raw error details do
not appear in the result. This method does not inherit the ambiguous `false`
fallback from `checkWhitelist`.

**Example**
```typescript
const readiness = await client.compliance.checkReadiness('G_USER_PUBLIC_KEY');
if (readiness.state === 'unavailable') {
// Offer a read retry; do not infer rejection or resubmit a transaction.
}
```

See [Compliance Readiness](./compliance-readiness.md) for state semantics,
reason codes, dashboard integration, and protocol-only limitations.

---

Expand Down Expand Up @@ -198,4 +234,5 @@ Soroban transactions and RPC queries can fail for several reasons. The SDK manag
3. **XDR Parsing Errors:** If the contract returns data that does not match the expected return type.
4. **Safe Read Model Fallbacks:** Portfolio queries intercept network/RPC failures and return an `InvestorPortfolio` with `status: 'unavailable'` to prevent frontend application crashes.

> **Open note:** as described above under [Exported Types & Errors](#exported-types--errors-srcindexts), point 4 (safe fallbacks) matches what `InvestorModule.getPortfolio` does today, but `PortfolioError` itself is not currently thrown by `checkWhitelist`, `mint`, or `transfer` — those surface plain `Error` objects instead. Treat this section as the intended error-handling strategy for the SDK rather than a description of every method's current exact error type.
> **Open note:** as described above under [Exported Types & Errors](#exported-types--errors-srcindexts), point 4 (safe fallbacks) matches what `InvestorModule.getPortfolio` does today, but `PortfolioError` itself is not currently thrown by `checkWhitelist`, `mint`, or `transfer`. The compliance read uses classified `NetworkFailure` errors for network operations and can also expose argument/decode errors; the asset methods surface plain `Error` objects. Treat this section as the intended error-handling strategy for the SDK rather than a description of every method's current exact error type.

46 changes: 46 additions & 0 deletions docs/compliance-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Compliance Readiness

`ComplianceModule.checkReadiness(address)` gives dashboards and SDK consumers one stable model for deciding how to present protocol eligibility before a restricted RWA action.

It is deliberately **not** a legal or KYC determination. The SDK reports only protocol-visible state. The Soroban contract remains the final authority for whether a state-changing operation succeeds.

## Result model

```ts
const readiness = await aegis.compliance.checkReadiness(address);

if (readiness.state === 'approved') {
// The current protocol whitelist read allows the restricted-action UX.
}
```

Every result contains `state`, `eligible`, `verified`, a stable `code`, a human-readable `reason`, the queried `address`, and `checkedAt`.

| State | Eligible | Meaning for UI |
| --- | --- | --- |
| `approved` | yes | Protocol status currently allows the restricted-action UX. |
| `blocked` | no | The observed whitelist read is negative. |
| `revoked` | no | A richer protocol/indexer source explicitly reports revocation. |
| `pending` | no | Approval is not complete; do not optimistically enable the action. |
| `unknown` | no | The status does not establish eligibility, or the address is invalid. |
| `unavailable` | no | The status could not be read; offer a safe retry instead of guessing. |

The deployed contract currently exposes a boolean `is_whitelisted` read, so live `checkReadiness()` calls resolve to `approved`, `blocked`, `unknown` (invalid input), or `unavailable` (query failure). `mapComplianceReadiness()` also normalises `revoked`, `pending`, and `unknown` values so dashboards or future protocol/indexer integrations can adopt richer states without changing their UI state machine.

## Stable reason codes

Use `code` for program logic rather than parsing `reason` text:

- `APPROVED`
- `NOT_WHITELISTED`
- `REVOKED`
- `PENDING_REVIEW`
- `STATUS_UNKNOWN`
- `STATUS_UNAVAILABLE`
- `INVALID_ADDRESS`

## Dashboard guidance

Treat every state except `approved` as non-eligible for restricted actions. Show a state-specific explanation for `blocked`, `revoked`, and `pending`. For `unknown` or `unavailable`, prefer a retry/status-refresh affordance and avoid presenting a definitive compliance conclusion.

Do not use readiness as authorization and do not infer off-chain KYC, accreditation, sanctions, or legal status from it. Never blindly resubmit a state-changing transaction because a readiness read failed; refresh the read first, then let the contract enforce the operation.
170 changes: 156 additions & 14 deletions src/compliance.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,179 @@
import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk';
import { BASE_FEE, Contract, nativeToScVal, rpc, scValToNative, StrKey, TransactionBuilder } from '@stellar/stellar-sdk';
import { AegisClient } from './client';
import { parseSorobanResult } from './utils/xdr-parser';
import {
ComplianceProtocolStatus,
ComplianceReadinessResult,
} from './types/compliance-readiness';

/**
* Maps protocol/indexer compliance status into a stable SDK readiness model.
*
* This function describes protocol state only. It does not assert legal KYC,
* accreditation, sanctions screening, or any other off-chain determination.
*/
export function mapComplianceReadiness(
address: string,
status: ComplianceProtocolStatus,
checkedAt = new Date().toISOString()
): ComplianceReadinessResult {
switch (status) {
case true:
case 'approved':
case 'whitelisted':
return {
address,
state: 'approved',
eligible: true,
verified: true,
code: 'APPROVED',
reason: 'Address is approved by the observed protocol whitelist state.',
checkedAt,
};
case false:
case 'blocked':
case 'not_whitelisted':
return {
address,
state: 'blocked',
eligible: false,
verified: true,
code: 'NOT_WHITELISTED',
reason: 'Address is not approved by the observed protocol whitelist state.',
checkedAt,
};
case 'revoked':
return {
address,
state: 'revoked',
eligible: false,
verified: true,
code: 'REVOKED',
reason: 'Protocol eligibility was revoked.',
checkedAt,
};
case 'pending':
return {
address,
state: 'pending',
eligible: false,
verified: true,
code: 'PENDING_REVIEW',
reason: 'Protocol eligibility is pending and should not be treated as approved.',
checkedAt,
};
case 'unknown':
return {
address,
state: 'unknown',
eligible: false,
verified: true,
code: 'STATUS_UNKNOWN',
reason: 'Protocol returned a status that does not establish eligibility.',
checkedAt,
};
case 'unavailable':
case null:
case undefined:
default:
return {
address,
state: 'unavailable',
eligible: false,
verified: false,
code: 'STATUS_UNAVAILABLE',
reason: 'Compliance status is unavailable. Retry the read before enabling restricted actions.',
checkedAt,
};
}
}

export class ComplianceModule {
private client: AegisClient;
private client: AegisClient;

constructor(client: AegisClient) {
constructor(client: AegisClient) {
this.client = client;
}

/**
* Queries the contract to check if a user is KYC-approved (whitelisted).
* Queries the contract to check if a user is protocol-whitelisted.
* @param address The Stellar public key to check.
* @returns boolean indicating whitelist status.
*/
public async checkWhitelist(address: string): Promise<boolean> {
private async simulateWhitelist(address: string): Promise<rpc.Api.SimulateTransactionResponse> {
const contract = new Contract(this.client.contractId);

// Create the invocation for the read-only 'is_whitelisted' function
const call = contract.call('is_whitelisted', nativeToScVal(address, { type: 'address' }));

const result = await this.client.runNetworkOperation(() =>
this.client.rpcServer.simulateTransaction({
// Dummy transaction for simulation purposes
transaction: call as any, // Cast required depending on SDK version wrapper
} as any)
);
return this.client.runNetworkOperation(async () => {
const sourceAddress = this.client.keypair?.publicKey() ?? address;
const account = await this.client.rpcServer.getAccount(sourceAddress);
const transaction = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: this.client.networkPassphrase,
})
.addOperation(call)
.setTimeout(30)
.build();

// Simulation needs a serialisable transaction, but never a signature or submission.
return this.client.rpcServer.simulateTransaction(transaction);
});
}

public async checkWhitelist(address: string): Promise<boolean> {
const result = await this.simulateWhitelist(address);

// rpc.Api.isSimulationSuccess acts as a type guard here
// Check for success AND ensure the result object actually exists
if (rpc.Api.isSimulationSuccess(result) && result.result) {
return parseSorobanResult(result.result.retval as any) as boolean;
if (rpc.Api.isSimulationSuccess(result) && result.result && !rpc.Api.isSimulationRestore(result)) {
return scValToNative(result.result.retval) === true;
}
return false;
}

/**
* Returns a dashboard-safe readiness result for restricted protocol actions.
*
* The deployed contract currently exposes `is_whitelisted`, so a successful
* live read resolves to `approved` or `blocked`. The public readiness model
* also represents `revoked`, `pending`, `unknown`, and `unavailable` so richer
* protocol/indexer sources can be normalised without changing consumer logic.
*
* This is protocol state, not proof of off-chain legal/KYC status. Contracts
* remain the final authority for whether any state-changing operation succeeds.
*/
public async checkReadiness(address: string): Promise<ComplianceReadinessResult> {
const checkedAt = new Date().toISOString();

if (!StrKey.isValidEd25519PublicKey(address)) {
return {
address,
state: 'unknown',
eligible: false,
verified: false,
code: 'INVALID_ADDRESS',
reason: 'A valid Stellar public key is required for a compliance readiness check.',
checkedAt,
};
}

try {
const result = await this.simulateWhitelist(address);
if (!rpc.Api.isSimulationSuccess(result) || !result.result || rpc.Api.isSimulationRestore(result)) {
return mapComplianceReadiness(address, 'unavailable', checkedAt);
}

// RPC success responses already contain a parsed ScVal, not a base64 string.
const status = scValToNative(result.result.retval);
return mapComplianceReadiness(
address,
typeof status === 'boolean' ? status : 'unknown',
checkedAt
);
} catch {
return mapComplianceReadiness(address, 'unavailable', checkedAt);
}
}
}

3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type {
} from './client-factory';
export * from './types/client-factory';
export * from './errors/client-factory';
export { ComplianceModule } from './compliance';
export { ComplianceModule, mapComplianceReadiness } from './compliance';
export { AssetModule } from './asset';
export { InvestorModule } from './investor/portfolio';
export { RoleModule } from './role';
Expand Down Expand Up @@ -47,6 +47,7 @@ export * from './errors/portfolio';
export * from './types/role';
export * from './errors/role';
export * from './types/admin-receipt';
export * from './types/compliance-readiness';
export * from './errors/network';
export * from './errors/config';
export * from './types/contract-event';
Expand Down
Loading