From 0cfa7146e5301ce24b69e30ea3c75259cc33994e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:50:33 -0400 Subject: [PATCH 1/8] feat: add compliance readiness types --- src/types/compliance-readiness.ts | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/types/compliance-readiness.ts diff --git a/src/types/compliance-readiness.ts b/src/types/compliance-readiness.ts new file mode 100644 index 0000000..7f4b345 --- /dev/null +++ b/src/types/compliance-readiness.ts @@ -0,0 +1,50 @@ +/** Protocol-facing readiness state for restricted Raegis operations. */ +export type ComplianceReadinessState = + | 'approved' + | 'blocked' + | 'revoked' + | 'pending' + | 'unknown' + | 'unavailable'; + +/** Stable reason codes suitable for dashboard logic and support diagnostics. */ +export type ComplianceReadinessReasonCode = + | 'APPROVED' + | 'NOT_WHITELISTED' + | 'REVOKED' + | 'PENDING_REVIEW' + | 'STATUS_UNKNOWN' + | 'STATUS_UNAVAILABLE' + | 'INVALID_ADDRESS'; + +/** + * Protocol status values accepted by the normaliser. + * + * The deployed contract currently exposes a boolean `is_whitelisted` read. + * String states keep the public SDK model forward-compatible with richer + * protocol/indexer status sources without changing dashboard state handling. + */ +export type ComplianceProtocolStatus = + | boolean + | 'approved' + | 'whitelisted' + | 'blocked' + | 'not_whitelisted' + | 'revoked' + | 'pending' + | 'unknown' + | 'unavailable' + | null + | undefined; + +export interface ComplianceReadinessResult { + address: string; + state: ComplianceReadinessState; + /** True only when the observed protocol state allows a restricted action. */ + eligible: boolean; + /** True when the state came from a successful protocol status read. */ + verified: boolean; + code: ComplianceReadinessReasonCode; + reason: string; + checkedAt: string; +} From f780aaaa63cfc6310adcb4c69aae9affcf93678a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:50:51 -0400 Subject: [PATCH 2/8] feat: expose typed compliance readiness --- src/compliance.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/src/compliance.ts b/src/compliance.ts index 04e0be7..3e9354b 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,16 +1,102 @@ -import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; +import { Contract, nativeToScVal, rpc, StrKey } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; +import { + ComplianceProtocolStatus, + ComplianceReadinessResult, +} from './types/compliance-readiness'; import { parseSorobanResult } from './utils/xdr-parser'; +/** + * 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. */ @@ -30,8 +116,42 @@ constructor(client: AegisClient) { // 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; + return parseSorobanResult(result.result.retval as any) as boolean; } 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 { + 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 isWhitelisted = await this.checkWhitelist(address); + return mapComplianceReadiness(address, isWhitelisted, checkedAt); + } catch { + return mapComplianceReadiness(address, 'unavailable', checkedAt); + } + } } From c2b5c2e53759a385075fe0751c75696b23421b1b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:51:04 -0400 Subject: [PATCH 3/8] feat: export compliance readiness API --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index b498dcf..5800faf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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'; From 55cc50d112efd11bb3d38b95c5614c302748bc56 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:51:17 -0400 Subject: [PATCH 4/8] test: cover compliance readiness states --- tests/compliance-readiness.test.ts | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/compliance-readiness.test.ts diff --git a/tests/compliance-readiness.test.ts b/tests/compliance-readiness.test.ts new file mode 100644 index 0000000..2472c5b --- /dev/null +++ b/tests/compliance-readiness.test.ts @@ -0,0 +1,65 @@ +import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { AegisClient } from '../src/client'; +import { mapComplianceReadiness } from '../src/compliance'; +import { ComplianceProtocolStatus } from '../src/types/compliance-readiness'; + +describe('compliance readiness', () => { + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + const makeClient = () => + new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId, + }); + + test.each<[ComplianceProtocolStatus, string, boolean, string]>([ + [true, 'approved', true, 'APPROVED'], + [false, 'blocked', false, 'NOT_WHITELISTED'], + ['revoked', 'revoked', false, 'REVOKED'], + ['pending', 'pending', false, 'PENDING_REVIEW'], + ['unknown', 'unknown', false, 'STATUS_UNKNOWN'], + ['unavailable', 'unavailable', false, 'STATUS_UNAVAILABLE'], + ])('maps %p to %s readiness', (status, state, eligible, code) => { + const result = mapComplianceReadiness('GTEST', status, '2026-10-08T00:00:00.000Z'); + expect(result.state).toBe(state); + expect(result.eligible).toBe(eligible); + expect(result.code).toBe(code); + }); + + it('returns approved after a successful whitelist read', async () => { + const client = makeClient(); + jest.spyOn(client.compliance, 'checkWhitelist').mockResolvedValue(true); + const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + expect(result.state).toBe('approved'); + expect(result.eligible).toBe(true); + expect(result.verified).toBe(true); + }); + + it('returns blocked after a successful negative whitelist read', async () => { + const client = makeClient(); + jest.spyOn(client.compliance, 'checkWhitelist').mockResolvedValue(false); + const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + expect(result.state).toBe('blocked'); + expect(result.code).toBe('NOT_WHITELISTED'); + expect(result.verified).toBe(true); + }); + + it('returns unavailable when the compliance read fails', async () => { + const client = makeClient(); + jest.spyOn(client.compliance, 'checkWhitelist').mockRejectedValue(new Error('RPC unavailable')); + const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + expect(result.state).toBe('unavailable'); + expect(result.code).toBe('STATUS_UNAVAILABLE'); + expect(result.verified).toBe(false); + expect(result.reason).not.toContain('RPC unavailable'); + }); + + it('rejects an invalid address without performing a compliance read', async () => { + const client = makeClient(); + const spy = jest.spyOn(client.compliance, 'checkWhitelist'); + const result = await client.compliance.checkReadiness('not-a-stellar-address'); + expect(result.state).toBe('unknown'); + expect(result.code).toBe('INVALID_ADDRESS'); + expect(spy).not.toHaveBeenCalled(); + }); +}); From 60e5ff5d2a245e08cc33f10ddf8f7cc78da990ce Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:51:32 -0400 Subject: [PATCH 5/8] docs: explain compliance readiness states --- docs/compliance-readiness.md | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/compliance-readiness.md diff --git a/docs/compliance-readiness.md b/docs/compliance-readiness.md new file mode 100644 index 0000000..014dea6 --- /dev/null +++ b/docs/compliance-readiness.md @@ -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. From c58a85085f79bb67d33f339f895cad37b00ec0f9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 05:51:55 -0400 Subject: [PATCH 6/8] docs: link compliance readiness guidance --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 4d2bb5a..0848cce 100644 --- a/README.md +++ b/README.md @@ -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. From ef76fbb6eac2fe77cd2086c1e8da5188b2ebad92 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 8 Oct 2026 06:12:27 -0400 Subject: [PATCH 7/8] fix: distinguish unavailable compliance readiness reads --- docs/api-reference.md | 30 +++++++++++++ src/compliance.ts | 26 ++++++++--- tests/compliance-readiness.test.ts | 71 +++++++++++++++++++++++++++--- 3 files changed, 117 insertions(+), 10 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..baa7168 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -70,6 +70,35 @@ try { > **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. +### `checkReadiness(address: string): Promise` + +**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. + --- ## `AssetModule` @@ -199,3 +228,4 @@ Soroban transactions and RPC queries can fail for several reasons. The SDK manag 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. + diff --git a/src/compliance.ts b/src/compliance.ts index 3e9354b..8da0e4f 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,4 +1,4 @@ -import { Contract, nativeToScVal, rpc, StrKey } from '@stellar/stellar-sdk'; +import { Contract, nativeToScVal, rpc, scValToNative, StrKey } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; import { ComplianceProtocolStatus, @@ -100,19 +100,24 @@ export class ComplianceModule { * @param address The Stellar public key to check. * @returns boolean indicating whitelist status. */ - public async checkWhitelist(address: string): Promise { + private async simulateWhitelist(address: string): Promise { 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(() => + return 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) ); + } + + public async checkWhitelist(address: string): Promise { + 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) { @@ -148,10 +153,21 @@ export class ComplianceModule { } try { - const isWhitelisted = await this.checkWhitelist(address); - return mapComplianceReadiness(address, isWhitelisted, checkedAt); + 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); } } } + diff --git a/tests/compliance-readiness.test.ts b/tests/compliance-readiness.test.ts index 2472c5b..a66a16d 100644 --- a/tests/compliance-readiness.test.ts +++ b/tests/compliance-readiness.test.ts @@ -1,4 +1,4 @@ -import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { Keypair, Networks, rpc, SorobanDataBuilder, xdr } from '@stellar/stellar-sdk'; import { AegisClient } from '../src/client'; import { mapComplianceReadiness } from '../src/compliance'; import { ComplianceProtocolStatus } from '../src/types/compliance-readiness'; @@ -12,6 +12,17 @@ describe('compliance readiness', () => { contractId, }); + const simulation = (whitelisted: boolean): rpc.Api.SimulateTransactionSuccessResponse => ({ + id: 'readiness', + latestLedger: 1, + events: [], + _parsed: true, + transactionData: new SorobanDataBuilder(), + minResourceFee: '0', + cost: { cpuInsns: '0', memBytes: '0' }, + result: { auth: [], retval: xdr.ScVal.scvBool(whitelisted) }, + }); + test.each<[ComplianceProtocolStatus, string, boolean, string]>([ [true, 'approved', true, 'APPROVED'], [false, 'blocked', false, 'NOT_WHITELISTED'], @@ -28,7 +39,7 @@ describe('compliance readiness', () => { it('returns approved after a successful whitelist read', async () => { const client = makeClient(); - jest.spyOn(client.compliance, 'checkWhitelist').mockResolvedValue(true); + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue(simulation(true)); const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); expect(result.state).toBe('approved'); expect(result.eligible).toBe(true); @@ -37,7 +48,7 @@ describe('compliance readiness', () => { it('returns blocked after a successful negative whitelist read', async () => { const client = makeClient(); - jest.spyOn(client.compliance, 'checkWhitelist').mockResolvedValue(false); + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue(simulation(false)); const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); expect(result.state).toBe('blocked'); expect(result.code).toBe('NOT_WHITELISTED'); @@ -46,7 +57,7 @@ describe('compliance readiness', () => { it('returns unavailable when the compliance read fails', async () => { const client = makeClient(); - jest.spyOn(client.compliance, 'checkWhitelist').mockRejectedValue(new Error('RPC unavailable')); + jest.spyOn(client, 'runNetworkOperation').mockRejectedValue(new Error('RPC unavailable')); const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); expect(result.state).toBe('unavailable'); expect(result.code).toBe('STATUS_UNAVAILABLE'); @@ -54,12 +65,62 @@ describe('compliance readiness', () => { expect(result.reason).not.toContain('RPC unavailable'); }); + it('distinguishes unsuccessful simulation from a negative whitelist result', async () => { + const client = makeClient(); + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue({ + id: 'readiness', + latestLedger: 1, + events: [], + _parsed: true, + error: 'simulation unavailable', + }); + const address = Keypair.random().publicKey(); + const result = await client.compliance.checkReadiness(address); + expect(result.state).toBe('unavailable'); + expect(result.verified).toBe(false); + expect(await client.compliance.checkWhitelist(address)).toBe(false); + }); + + it('returns unavailable when simulation succeeds without an invocation result', async () => { + const client = makeClient(); + const response = simulation(false); + delete response.result; + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue(response); + const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + expect(result.state).toBe('unavailable'); + expect(result.verified).toBe(false); + }); + + it('does not approve an address when ledger restoration is still required', async () => { + const client = makeClient(); + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue({ + ...simulation(true), + restorePreamble: { minResourceFee: '0', transactionData: new SorobanDataBuilder() }, + }); + const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + expect(result.state).toBe('unavailable'); + expect(result.eligible).toBe(false); + expect(result.verified).toBe(false); + }); + + it('reports unknown for a successful read returning a non-boolean value', async () => { + const client = makeClient(); + const response = simulation(false); + response.result!.retval = xdr.ScVal.scvString('unexpected'); + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue(response); + const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + expect(result.state).toBe('unknown'); + expect(result.eligible).toBe(false); + expect(result.verified).toBe(true); + }); + it('rejects an invalid address without performing a compliance read', async () => { const client = makeClient(); - const spy = jest.spyOn(client.compliance, 'checkWhitelist'); + const spy = jest.spyOn(client, 'runNetworkOperation'); const result = await client.compliance.checkReadiness('not-a-stellar-address'); expect(result.state).toBe('unknown'); expect(result.code).toBe('INVALID_ADDRESS'); expect(spy).not.toHaveBeenCalled(); }); }); + From b85d5f4e950e7e1374555d924dd48d5285678ac2 Mon Sep 17 00:00:00 2001 From: woahwhattheheck <293286387+woahwhattheheck@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:28:12 -0400 Subject: [PATCH 8/8] fix(compliance): build unsigned transactions for whitelist reads [skip ci] --- docs/api-reference.md | 17 ++++++++--- src/compliance.ts | 26 ++++++++++------ tests/compliance-readiness.test.ts | 49 ++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index baa7168..ade4909 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -42,10 +42,10 @@ public async checkWhitelist(address: string): Promise * `address` (string): The Stellar public key (`G...`) to check. **Returns** -`Promise` — `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` — `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** @@ -63,12 +63,19 @@ 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` @@ -227,5 +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. diff --git a/src/compliance.ts b/src/compliance.ts index 8da0e4f..e36810a 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,10 +1,9 @@ -import { Contract, nativeToScVal, rpc, scValToNative, StrKey } from '@stellar/stellar-sdk'; +import { BASE_FEE, Contract, nativeToScVal, rpc, scValToNative, StrKey, TransactionBuilder } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; import { ComplianceProtocolStatus, ComplianceReadinessResult, } from './types/compliance-readiness'; -import { parseSorobanResult } from './utils/xdr-parser'; /** * Maps protocol/indexer compliance status into a stable SDK readiness model. @@ -106,13 +105,20 @@ export class ComplianceModule { // Create the invocation for the read-only 'is_whitelisted' function const call = contract.call('is_whitelisted', nativeToScVal(address, { type: 'address' })); - return 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 { @@ -120,8 +126,8 @@ export class ComplianceModule { // 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; } diff --git a/tests/compliance-readiness.test.ts b/tests/compliance-readiness.test.ts index a66a16d..27edf3b 100644 --- a/tests/compliance-readiness.test.ts +++ b/tests/compliance-readiness.test.ts @@ -1,4 +1,4 @@ -import { Keypair, Networks, rpc, SorobanDataBuilder, xdr } from '@stellar/stellar-sdk'; +import { Account, BASE_FEE, Keypair, Networks, rpc, scValToNative, SorobanDataBuilder, Transaction, xdr } from '@stellar/stellar-sdk'; import { AegisClient } from '../src/client'; import { mapComplianceReadiness } from '../src/compliance'; import { ComplianceProtocolStatus } from '../src/types/compliance-readiness'; @@ -55,6 +55,49 @@ describe('compliance readiness', () => { expect(result.verified).toBe(true); }); + test.each([false, true])('builds a serialisable unsigned read with configured source %p', async (withSigner) => { + const address = Keypair.random().publicKey(); + const keypair = Keypair.random(); + const client = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId, + ...(withSigner ? { keypair } : {}), + }); + const source = withSigner ? keypair.publicKey() : address; + const getAccount = jest.spyOn(client.rpcServer, 'getAccount') + .mockResolvedValue(new Account(source, '42')); + const submit = jest.spyOn(client.rpcServer, 'sendTransaction'); + jest.spyOn(client.rpcServer, 'simulateTransaction').mockImplementation(async (transaction) => { + expect(transaction).toBeInstanceOf(Transaction); + const read = transaction as Transaction; + expect(read.source).toBe(source); + expect(read.sequence).toBe('43'); + expect(read.fee).toBe(BASE_FEE); + expect(read.networkPassphrase).toBe(Networks.TESTNET); + expect(read.signatures).toHaveLength(0); + expect(read.operations).toHaveLength(1); + const operation = read.operations[0]; + expect(operation.type).toBe('invokeHostFunction'); + if (operation.type !== 'invokeHostFunction') throw new Error('Expected contract read'); + const invocation = operation.func.invokeContract(); + expect(invocation.functionName().toString()).toBe('is_whitelisted'); + expect(scValToNative(invocation.args()[0])).toBe(address); + // Exercise the actual serialization that rpc.Server.simulateTransaction requires. + expect(read.toXDR()).toEqual(expect.any(String)); + return simulation(true); + }); + expect((await client.compliance.checkReadiness(address)).state).toBe('approved'); + expect(getAccount).toHaveBeenCalledWith(source); + expect(submit).not.toHaveBeenCalled(); + }); + + test.each([true, false])('decodes the legacy parsed whitelist boolean %p', async (whitelisted) => { + const client = makeClient(); + jest.spyOn(client, 'runNetworkOperation').mockResolvedValue(simulation(whitelisted)); + expect(await client.compliance.checkWhitelist(Keypair.random().publicKey())).toBe(whitelisted); + }); + it('returns unavailable when the compliance read fails', async () => { const client = makeClient(); jest.spyOn(client, 'runNetworkOperation').mockRejectedValue(new Error('RPC unavailable')); @@ -97,10 +140,12 @@ describe('compliance readiness', () => { ...simulation(true), restorePreamble: { minResourceFee: '0', transactionData: new SorobanDataBuilder() }, }); - const result = await client.compliance.checkReadiness(Keypair.random().publicKey()); + const address = Keypair.random().publicKey(); + const result = await client.compliance.checkReadiness(address); expect(result.state).toBe('unavailable'); expect(result.eligible).toBe(false); expect(result.verified).toBe(false); + expect(await client.compliance.checkWhitelist(address)).toBe(false); }); it('reports unknown for a successful read returning a non-boolean value', async () => {