From 37f6de1966e163f4821be33875d1532443673486 Mon Sep 17 00:00:00 2001 From: panditdhamdhere Date: Wed, 29 Jul 2026 22:20:59 +0530 Subject: [PATCH] feat: add compliance batch query utilities (Closes #50) Add checkWhitelistBatch so admin compliance tables can resolve many investor addresses without unbounded RPC fan-out or all-or-nothing failure. Each input yields exactly one typed item in input order. Addresses are validated with StrKey before any RPC call, so invalid, muxed, and contract inputs are rejected per item instead of throwing mid-batch. Query failures carry the existing safe network diagnostic, and the batch roll-up reports counts and classified failure codes only, never addresses or raw provider errors. Concurrency is bounded (default 4) and identical addresses are queried once. No automatic retry is performed, since retrying inside a batch multiplies load exactly when a provider is already rate limiting. --- CONTRIBUTING.md | 3 +- README.md | 21 ++ docs/acceptance-criteria-traceability.md | 4 + docs/api-reference.md | 62 ++++- docs/compliance-batch-queries.md | 173 ++++++++++++ docs/test-first-contribution-guide.md | 6 +- docs/testing.md | 5 + src/client-factory.ts | 2 +- src/compliance.ts | 42 ++- src/compliance/batch.ts | 305 +++++++++++++++++++++ src/diagnostics/compliance.ts | 71 +++++ src/errors/compliance.ts | 15 ++ src/index.ts | 3 + src/testing/mock-client.ts | 16 ++ src/types/compliance-batch.ts | 117 ++++++++ tests/compliance-batch.test.ts | 328 +++++++++++++++++++++++ tests/mock-client.test.ts | 24 ++ 17 files changed, 1191 insertions(+), 6 deletions(-) create mode 100644 docs/compliance-batch-queries.md create mode 100644 src/compliance/batch.ts create mode 100644 src/diagnostics/compliance.ts create mode 100644 src/errors/compliance.ts create mode 100644 src/types/compliance-batch.ts create mode 100644 tests/compliance-batch.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62afcbb..a55bd6d 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/compliance-batch-queries.md` if compliance batching changes; `docs/investor-portfolio.md` if the investor read model 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 compliance batching, follow the checklist in `docs/compliance-batch-queries.md` (partial failures, concurrency/rate limits, and address-free diagnostics). - [ ] 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..a1a91ce 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,27 @@ const aegis = new AegisClient({ }); ``` +## Batch Compliance Queries + +Check an admin compliance table without unbounded RPC fan-out or all-or-nothing +failure: + +```typescript +const batch = await aegis.compliance.checkWhitelistBatch( + ['G_INVESTOR_ONE', 'G_INVESTOR_TWO', 'invalid-input'], + { concurrency: 4 }, +); + +for (const item of batch.items) { + console.log(item.index, item.status, item.isWhitelisted); +} +``` + +Results preserve input order, invalid addresses stay per-item, and query failures +carry safe diagnostics. The SDK deduplicates by default and does not retry +automatically during rate limiting. See +[Compliance Batch Queries](./docs/compliance-batch-queries.md). + ## Role Discovery & Capability Checks Check what an address is classified as, and what it can currently attempt through the SDK. This is a client-side convenience for UI gating, not on-chain authorization — see the diff --git a/docs/acceptance-criteria-traceability.md b/docs/acceptance-criteria-traceability.md index cdc3448..d23269a 100644 --- a/docs/acceptance-criteria-traceability.md +++ b/docs/acceptance-criteria-traceability.md @@ -41,6 +41,8 @@ Below is the complete map of every public SDK module. Reference this when comple |---|---|---|---| | Client | `src/client.ts` | `AegisClient` | Top-level SDK client; initializes RPC, configures modules, provides `runNetworkOperation()` | | Compliance | `src/compliance.ts` | `ComplianceModule` | Queries the Aegis Soroban contract for KYC/whitelist status | +| Compliance Batch | `src/compliance/batch.ts` | `ComplianceModule.checkWhitelistBatch` | Validates and queries multiple whitelist states with bounded concurrency and partial-failure isolation | +| Compliance Diagnostics | `src/diagnostics/compliance.ts` | `buildComplianceBatchDiagnostic` | Builds address-free batch failure roll-ups | | Asset | `src/asset.ts` | `AssetModule` | Submits mint and transfer transactions to the Soroban contract | | Role Discovery | `src/role.ts` | `RoleModule` | Client-side role classification and capability gating | | Admin Receipts | `src/admin/receipts.ts` | `normalizeAdminActionStatus`, `buildAdminTransactionExplorerUrl`, `buildAdminActionReceipt` | Builds serializable admin action receipts | @@ -66,6 +68,7 @@ Reference this when completing the "Test(s)" column. | Test File | Covers | |---|---| | `tests/client.test.ts` | Client configuration, module instantiation, signer requirements | +| `tests/compliance-batch.test.ts` | Batch validation, ordering, concurrency, partial failures, rate limits, safe diagnostics | | `tests/config.test.ts` | Environment presets, config validation, error cases | | `tests/role.test.ts` | Role discovery, capability checks, capability matrix | | `tests/investor.test.ts` | Portfolio fetching, balance calculations, status mapping | @@ -85,6 +88,7 @@ Reference this when completing the "Doc(s)" column. | Document | Content | |---|---| | `docs/api-reference.md` | Full API reference for all public modules | +| `docs/compliance-batch-queries.md` | Batch compliance usage, performance, rate limits, diagnostics, and security | | `docs/testing.md` | Mock client setup and testing patterns | | `docs/contract-events.md` | Event decoder usage and supported topics | | `docs/role-discovery.md` | Role discovery and capability gating | diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..4286617 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -45,7 +45,7 @@ public async checkWhitelist(address: string): Promise `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. **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. +* If `simulateTransaction` throws, the client network boundary classifies it as a typed, safe `NetworkFailure`. Synchronous address/XDR construction and result parsing remain outside that boundary and may throw their underlying error. * A failed/unsuccessful simulation that does *not* throw is swallowed and reported as `false` (see Returns above), not as an error. **Example** @@ -70,6 +70,66 @@ 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. +### `checkWhitelistBatch(addresses, options?): Promise` + +Checks multiple investor addresses with per-item validation, bounded +concurrency, input-order preservation, and safe partial-failure mapping. + +**Signature** +```typescript +public async checkWhitelistBatch( + addresses: readonly string[], + options: ComplianceBatchOptions = {}, +): Promise +``` + +**Parameters** +* `addresses` (`readonly string[]`): Stellar account public keys (`G...`). Runtime non-string, malformed, muxed (`M...`), and contract (`C...`) inputs become per-item `invalid-address` results without reaching RPC. +* `options.concurrency` (number, optional): Maximum simultaneous checks. Defaults to `4`; integer range 1–20. +* `options.deduplicate` (boolean, optional): Query identical valid addresses once and fan out the result. Defaults to `true`. +* `options.maxBatchSize` (number, optional): Maximum accepted input length. Defaults to `100`; integer range 1–1000. + +**Returns** +`Promise` with: + +* one frozen item per input, in original order; +* item statuses `whitelisted`, `not-whitelisted`, `invalid-address`, or `failed`; +* `isWhitelisted: false` on invalid/failed items (fail closed, but callers must branch on `status`); +* safe `NetworkFailureDiagnostic` data on failed items; +* counts, actual RPC query count, duplicate count, partial/exhausted flags, rate-limit flag, duration, and fetch timestamp. + +An empty input returns a successful empty result. Individual invalid inputs, +network failures, and parse failures do not reject the batch. Invalid arbitrary +input is not echoed; correlate through `item.index`. + +**Errors** +Throws `ComplianceBatchError` before RPC when the runtime input is not an array +(`INVALID_BATCH_INPUT`), exceeds `maxBatchSize` (`BATCH_TOO_LARGE`), or contains +invalid options (`INVALID_BATCH_OPTIONS`). Expected per-item failures are returned, +not thrown. + +**Example** +```typescript +const result = await client.compliance.checkWhitelistBatch( + ['G_INVESTOR_ONE', 'G_INVESTOR_TWO'], + { concurrency: 4 }, +); + +for (const item of result.items) { + console.log(item.index, item.status, item.isWhitelisted); +} +``` + +### `buildComplianceBatchDiagnostic(result): ComplianceBatchDiagnostic` + +Builds a frozen, address-free roll-up for telemetry and support reports. It +contains counts, classified failure-code counts, a recovery action, and the +largest safe `retryAfterSeconds` value. It never includes item addresses, +original invalid input, raw errors, RPC URLs, headers, or credentials. + +See [Compliance Batch Queries](./compliance-batch-queries.md) for performance, +rate-limit, retry, dashboard, and security guidance. + --- ## `AssetModule` diff --git a/docs/compliance-batch-queries.md b/docs/compliance-batch-queries.md new file mode 100644 index 0000000..6ea2e6e --- /dev/null +++ b/docs/compliance-batch-queries.md @@ -0,0 +1,173 @@ +# Compliance batch queries + +Admin dashboards often need whitelist status for many investor rows. Calling +`checkWhitelist` in an unbounded `Promise.all` can overload an RPC provider and +makes one rejected request difficult to represent without losing the rest. + +`ComplianceModule.checkWhitelistBatch` validates each input, limits concurrent +RPC work, and returns exactly one typed item per input in the original order. + +## Quickstart + +```typescript +const result = await client.compliance.checkWhitelistBatch( + ['G_INVESTOR_ONE', 'G_INVESTOR_TWO', 'invalid-input'], + { concurrency: 4 }, +); + +for (const item of result.items) { + if (item.status === 'whitelisted') { + renderApprovedRow(item.index); + } else if (item.status === 'failed') { + renderRetryRow(item.index, item.diagnostic.code); + } else { + renderNotApprovedRow(item.index, item.code); + } +} +``` + +Use `item.index` to correlate with the original array. Valid addresses are +included on resolved/failed items. Invalid input is deliberately omitted from +the result because callers can accidentally pass tokens, URLs, or other +sensitive strings into a bulk-input field. + +## Per-item states + +| `status` | Meaning | +| --- | --- | +| `whitelisted` | Contract query resolved `true`. | +| `not-whitelisted` | Contract query resolved `false`. | +| `invalid-address` | Input was rejected before RPC. | +| `failed` | Valid address, but its query could not be evaluated. | + +Invalid-item codes distinguish: + +- `INVALID_ADDRESS` +- `MUXED_ADDRESS_UNSUPPORTED` +- `CONTRACT_ADDRESS_UNSUPPORTED` + +Failures use `COMPLIANCE_QUERY_FAILED` and carry a safe +`NetworkFailureDiagnostic`. Raw provider messages, request payloads, RPC URLs, +headers, and credentials are never copied into an item. + +`isWhitelisted` is present on every item and fails closed (`false`) for invalid +and failed items. Dashboards should still branch on `status`: `false` does not +distinguish a confirmed non-whitelisted result from an unavailable result. + +## Partial failures + +The batch promise does not reject because one address is invalid or one RPC +request fails. Instead: + +- successful addresses retain their resolved status; +- invalid addresses receive an `invalid-address` item; +- RPC/parse failures receive a `failed` item with a safe diagnostic. + +`summary.partial` is true when at least one query resolved and at least one +failed. `summary.exhausted` is true when every valid item failed. + +Batch-level configuration errors still throw `ComplianceBatchError`: + +- `INVALID_BATCH_INPUT`: runtime input is not an array; +- `BATCH_TOO_LARGE`: input exceeds `maxBatchSize`; +- `INVALID_BATCH_OPTIONS`: concurrency, deduplication, or size options are invalid. + +## Performance assumptions + +Soroban RPC does not provide one batch simulation call for this contract method. +Therefore **N unique valid addresses require N `simulateTransaction` requests**. + +Defaults: + +- `concurrency: 4` (allowed range 1–20); +- `deduplicate: true`; +- `maxBatchSize: 100` (configurable up to 1000). + +Deduplication queries an identical valid address once and fans the same result +back to every original position. Duplicate items set `duplicate: true`; +`summary.queried` records actual RPC requests rather than input rows. + +Choose concurrency based on the provider's documented quota, deployment +latency, and other traffic sharing the same API key. A larger value can reduce +wall-clock latency but increases burst load; it does not reduce total RPC calls. +Start with the default or lower it for shared/public endpoints. + +## Rate limits and retries + +The SDK deliberately performs **no automatic retry** inside a batch. Hidden +retries can multiply provider load precisely when it is already rate limiting. + +If an item is rate limited: + +1. Keep all successfully resolved items. +2. Build the address-free batch diagnostic. +3. Honor `retryAfterSeconds` when present. +4. Retry only the failed source rows after backoff, not the entire batch. + +```typescript +import { buildComplianceBatchDiagnostic } from '@aegis/sdk'; + +const diagnostic = buildComplianceBatchDiagnostic(result); + +if (diagnostic.action === 'retry-with-backoff') { + scheduleFailedRows(diagnostic.retryAfterSeconds); +} +``` + +`buildComplianceBatchDiagnostic` contains counts and classified failure codes +only. It intentionally excludes every address and original input, making it +suitable for telemetry and support reports. + +## Validation rules + +Only Stellar Ed25519 account public keys (`G...`) are accepted. Validation uses +`StrKey.isValidEd25519PublicKey` before contract/XDR construction: + +- empty, malformed, and non-string runtime input is invalid; +- muxed (`M...`) addresses are currently unsupported; +- contract (`C...`) addresses cannot identify investors. + +Validation is per item, so mixed input does not prevent valid rows from being +queried. + +## Mock-client usage + +The test-only client exposes the same method: + +```typescript +import { + createMockAegisClient, + createMockFixtures, +} from '@aegis/sdk/testing'; + +const fixtures = createMockFixtures(); +const client = createMockAegisClient(); +client.setWhitelisted(fixtures.investorAddress, true); + +const result = await client.compliance.checkWhitelistBatch([ + fixtures.investorAddress, + fixtures.secondaryInvestorAddress, +]); +``` + +This uses in-memory state and makes no network calls. + +## Security and compliance notes + +- Public keys are identifiers, but a list of investors under compliance review + can still be sensitive operational data. Keep batch results access-controlled. +- Use the address-free roll-up for logs and support tickets. +- Whitelist status reports protocol state; it is not legal, financial, or + regulatory advice. +- `not-whitelisted`, `invalid-address`, and `failed` are distinct. Do not display + an RPC outage as a compliance denial. + +## Contributor checklist + +- [ ] Results remain one-to-one with input and preserve order. +- [ ] Invalid inputs never reach RPC and are not echoed in results/diagnostics. +- [ ] One item failure never rejects otherwise valid batch work. +- [ ] Concurrency stays bounded and defaults remain documented. +- [ ] No automatic retry is introduced without explicit rate-limit analysis. +- [ ] Diagnostics remain free of addresses, raw errors, URLs, headers, and secrets. +- [ ] Tests cover mixed input, partial failure, rate limiting, ordering, and bounds. diff --git a/docs/test-first-contribution-guide.md b/docs/test-first-contribution-guide.md index fcd8d03..26b0e6a 100644 --- a/docs/test-first-contribution-guide.md +++ b/docs/test-first-contribution-guide.md @@ -50,7 +50,7 @@ file must cover **happy-path** and **negative-path** scenarios. ### Compliance Checks -Tests for `ComplianceModule` methods (`checkWhitelist`) must cover: +Tests for `ComplianceModule` methods (`checkWhitelist`, `checkWhitelistBatch`) must cover: | Scenario | Expectation | |---|---| @@ -58,6 +58,10 @@ Tests for `ComplianceModule` methods (`checkWhitelist`) must cover: | Non-whitelisted address | Returns `false` | | RPC failure / timeout | Throws or returns fallback (module-dependent) | | Invalid address (empty string) | Returns `false` or typed error | +| Mixed valid and invalid batch input | Preserves order; invalid items do not reach RPC | +| Partial batch RPC failure | Successful items remain resolved; failure is safe per-item | +| Batch concurrency / duplicates | Configured bound is honored; default deduplication avoids duplicate RPC work | +| Batch rate limiting | Safe diagnostic recommends backoff without exposing addresses or raw errors | ```typescript // Happy-path: whitelisted investor diff --git a/docs/testing.md b/docs/testing.md index c92da79..d674ea8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -31,6 +31,10 @@ client.setBalance(fixtures.investorAddress, '5000000000'); // raw integer (7 dec // Compliance const isApproved = await client.compliance.checkWhitelist(fixtures.investorAddress); +const batch = await client.compliance.checkWhitelistBatch([ + fixtures.investorAddress, + fixtures.secondaryInvestorAddress, +]); // Investor portfolio read model const portfolio = await client.investor.getPortfolio(fixtures.investorAddress); @@ -47,6 +51,7 @@ console.log(client.transactions[0]); // { hash, type, from, to, amount, timestam Creates an in-memory `MockAegisClient` with the same module surface as `AegisClient`: * `client.compliance.checkWhitelist(address)` → `Promise` +* `client.compliance.checkWhitelistBatch(addresses, options?)` → `Promise` * `client.asset.mint(to, amount)` → `Promise` (mock tx hash) * `client.asset.transfer(to, amount)` → `Promise` (mock tx hash) * `client.investor.getPortfolio(address, options?)` → `Promise` diff --git a/src/client-factory.ts b/src/client-factory.ts index fd891c7..2bca8f2 100644 --- a/src/client-factory.ts +++ b/src/client-factory.ts @@ -103,7 +103,7 @@ export interface AegisReadOnlyClient { readonly capabilities: RoleCapabilities; /** The underlying `AegisClient` instance. */ readonly client: AegisClient; - /** Compliance query module (read-only: checkWhitelist). */ + /** Compliance query module (read-only: single and batch whitelist checks). */ readonly compliance: ComplianceModule; /** Investor portfolio read module. */ readonly investor: InvestorModule; diff --git a/src/compliance.ts b/src/compliance.ts index 04e0be7..e7a8c20 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,5 +1,10 @@ import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; +import { executeComplianceBatch } from './compliance/batch'; +import { + ComplianceBatchOptions, + ComplianceBatchResult, +} from './types/compliance-batch'; import { parseSorobanResult } from './utils/xdr-parser'; export class ComplianceModule { @@ -15,6 +20,10 @@ constructor(client: AegisClient) { * @returns boolean indicating whitelist status. */ public async checkWhitelist(address: string): Promise { + return (await this.queryWhitelist(address)) ?? false; + } + + private async queryWhitelist(address: string): Promise { const contract = new Contract(this.client.contractId); // Create the invocation for the read-only 'is_whitelisted' function @@ -30,8 +39,37 @@ 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; + const parsed = parseSorobanResult(result.result.retval as any); + return typeof parsed === 'boolean' ? parsed : null; + } + return null; + } + + private async checkWhitelistForBatch(address: string): Promise { + const result = await this.queryWhitelist(address); + if (result === null) { + throw new SyntaxError( + 'Compliance simulation did not return a boolean result.', + ); } - return false; + return result; + } + + /** + * Checks multiple investor addresses with bounded concurrency. + * + * Invalid addresses and query failures are represented per item, so one bad + * input or RPC response never rejects the entire batch. Results preserve input + * order and duplicate valid addresses are queried once by default. + */ + public async checkWhitelistBatch( + addresses: readonly string[], + options: ComplianceBatchOptions = {}, + ): Promise { + return executeComplianceBatch( + addresses, + (address) => this.checkWhitelistForBatch(address), + options, + ); } } diff --git a/src/compliance/batch.ts b/src/compliance/batch.ts new file mode 100644 index 0000000..98e8e3c --- /dev/null +++ b/src/compliance/batch.ts @@ -0,0 +1,305 @@ +import { StrKey } from '@stellar/stellar-sdk'; +import { buildNetworkFailureDiagnostic } from '../diagnostics/network'; +import { ComplianceBatchError } from '../errors/compliance'; +import { + ComplianceBatchFailedItem, + ComplianceBatchInvalidItem, + ComplianceBatchItem, + ComplianceBatchOptions, + ComplianceBatchResolvedItem, + ComplianceBatchResult, +} from '../types/compliance-batch'; + +const DEFAULT_CONCURRENCY = 4; +const DEFAULT_MAX_BATCH_SIZE = 100; +const MAX_CONCURRENCY = 20; +const MAX_BATCH_SIZE_LIMIT = 1000; + +interface ResolvedOptions { + concurrency: number; + deduplicate: boolean; + maxBatchSize: number; +} + +interface QueryTask { + address: string; + indices: number[]; +} + +type WhitelistQuery = (address: string) => Promise; + +/** + * Executes compliance checks with bounded concurrency and per-item isolation. + * + * This helper does not retry automatically. A retry can multiply provider load, + * especially during rate limiting; callers should inspect diagnostics and retry + * only failed items after an appropriate delay. + */ +export async function executeComplianceBatch( + addresses: readonly string[], + queryWhitelist: WhitelistQuery, + options: ComplianceBatchOptions = {}, +): Promise { + if (!Array.isArray(addresses)) { + throw new ComplianceBatchError( + 'INVALID_BATCH_INPUT', + 'Compliance batch input must be an array of addresses.', + ); + } + + const settings = resolveOptions(options); + if (addresses.length > settings.maxBatchSize) { + throw new ComplianceBatchError( + 'BATCH_TOO_LARGE', + `Compliance batch cannot exceed ${settings.maxBatchSize} items.`, + ); + } + + const startedAt = Date.now(); + const fetchedAt = new Date(startedAt).toISOString(); + const items: Array = new Array( + addresses.length, + ); + const tasks = buildTasks(addresses, settings.deduplicate, items); + + let cursor = 0; + const workerCount = Math.min(settings.concurrency, tasks.length); + const workers = Array.from({ length: workerCount }, async () => { + while (cursor < tasks.length) { + const task = tasks[cursor]; + cursor += 1; + await executeTask(task, queryWhitelist, items); + } + }); + + await Promise.all(workers); + + const orderedItems = items.map((item) => { + if (!item) { + throw new ComplianceBatchError( + 'INVALID_BATCH_INPUT', + 'Compliance batch could not map every input item.', + ); + } + return item; + }); + + const whitelisted = countStatus(orderedItems, 'whitelisted'); + const notWhitelisted = countStatus(orderedItems, 'not-whitelisted'); + const invalid = countStatus(orderedItems, 'invalid-address'); + const failed = countStatus(orderedItems, 'failed'); + const resolved = whitelisted + notWhitelisted; + const validItems = resolved + failed; + const rateLimited = orderedItems.some( + (item) => + item.status === 'failed' && item.diagnostic.code === 'RATE_LIMITED', + ); + + const summary = Object.freeze({ + requested: addresses.length, + queried: tasks.length, + whitelisted, + notWhitelisted, + invalid, + failed, + duplicates: orderedItems.filter((item) => item.duplicate).length, + partial: failed > 0 && resolved > 0, + exhausted: validItems > 0 && failed === validItems, + rateLimited, + durationMs: Math.max(0, Date.now() - startedAt), + }); + + return Object.freeze({ + items: Object.freeze(orderedItems), + summary, + fetchedAt, + }); +} + +function buildTasks( + addresses: readonly string[], + deduplicate: boolean, + items: Array, +): QueryTask[] { + const tasks: QueryTask[] = []; + const taskByAddress = new Map(); + + addresses.forEach((value, index) => { + const invalid = validateAddress(value, index); + if (invalid) { + items[index] = invalid; + return; + } + + if (deduplicate) { + const existing = taskByAddress.get(value); + if (existing) { + existing.indices.push(index); + return; + } + } + + const task = { address: value, indices: [index] }; + tasks.push(task); + if (deduplicate) { + taskByAddress.set(value, task); + } + }); + + return tasks; +} + +async function executeTask( + task: QueryTask, + queryWhitelist: WhitelistQuery, + items: Array, +): Promise { + try { + const isWhitelisted = await queryWhitelist(task.address); + task.indices.forEach((index, position) => { + items[index] = Object.freeze({ + index, + address: task.address, + status: isWhitelisted ? 'whitelisted' : 'not-whitelisted', + code: 'OK', + isWhitelisted, + duplicate: position > 0, + message: isWhitelisted + ? 'Address is currently present on the protocol whitelist.' + : 'Address is not currently present on the protocol whitelist.', + } satisfies ComplianceBatchResolvedItem); + }); + } catch (error) { + const diagnostic = buildBatchFailureDiagnostic(error); + task.indices.forEach((index, position) => { + items[index] = Object.freeze({ + index, + address: task.address, + status: 'failed', + code: 'COMPLIANCE_QUERY_FAILED', + isWhitelisted: false, + duplicate: position > 0, + message: + 'Compliance status could not be evaluated for this address.', + diagnostic, + } satisfies ComplianceBatchFailedItem); + }); + } +} + +function buildBatchFailureDiagnostic(error: unknown) { + if ( + error instanceof Error && + error.message.trim().toUpperCase() === 'XDR PARSING FAILED.' + ) { + return buildNetworkFailureDiagnostic( + new SyntaxError('Invalid XDR response.'), + ); + } + + return buildNetworkFailureDiagnostic(error); +} + +function validateAddress( + value: unknown, + index: number, +): ComplianceBatchInvalidItem | undefined { + if (typeof value !== 'string' || !value.trim()) { + return buildInvalidItem( + index, + 'INVALID_ADDRESS', + 'Address is missing or invalid.', + ); + } + + if (StrKey.isValidEd25519PublicKey(value)) { + return undefined; + } + + if (StrKey.isValidMed25519PublicKey(value)) { + return buildInvalidItem( + index, + 'MUXED_ADDRESS_UNSUPPORTED', + 'Muxed addresses are not supported for compliance checks.', + ); + } + + if (StrKey.isValidContract(value)) { + return buildInvalidItem( + index, + 'CONTRACT_ADDRESS_UNSUPPORTED', + 'Contract addresses cannot be used as investor addresses.', + ); + } + + return buildInvalidItem( + index, + 'INVALID_ADDRESS', + 'Address is not a valid Stellar account public key.', + ); +} + +function buildInvalidItem( + index: number, + code: ComplianceBatchInvalidItem['code'], + message: string, +): ComplianceBatchInvalidItem { + return Object.freeze({ + index, + status: 'invalid-address', + code, + isWhitelisted: false, + duplicate: false, + message, + }); +} + +function resolveOptions(options: ComplianceBatchOptions): ResolvedOptions { + const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY; + const maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE; + + if ( + !Number.isInteger(concurrency) || + concurrency < 1 || + concurrency > MAX_CONCURRENCY + ) { + throw new ComplianceBatchError( + 'INVALID_BATCH_OPTIONS', + `concurrency must be an integer between 1 and ${MAX_CONCURRENCY}.`, + ); + } + + if ( + !Number.isInteger(maxBatchSize) || + maxBatchSize < 1 || + maxBatchSize > MAX_BATCH_SIZE_LIMIT + ) { + throw new ComplianceBatchError( + 'INVALID_BATCH_OPTIONS', + `maxBatchSize must be an integer between 1 and ${MAX_BATCH_SIZE_LIMIT}.`, + ); + } + + if ( + options.deduplicate !== undefined && + typeof options.deduplicate !== 'boolean' + ) { + throw new ComplianceBatchError( + 'INVALID_BATCH_OPTIONS', + 'deduplicate must be a boolean.', + ); + } + + return { + concurrency, + deduplicate: options.deduplicate ?? true, + maxBatchSize, + }; +} + +function countStatus( + items: readonly ComplianceBatchItem[], + status: ComplianceBatchItem['status'], +): number { + return items.filter((item) => item.status === status).length; +} diff --git a/src/diagnostics/compliance.ts b/src/diagnostics/compliance.ts new file mode 100644 index 0000000..769fd17 --- /dev/null +++ b/src/diagnostics/compliance.ts @@ -0,0 +1,71 @@ +import { + ComplianceBatchDiagnostic, + ComplianceBatchRecoveryAction, + ComplianceBatchResult, +} from '../types/compliance-batch'; +import { NetworkFailureCode } from '../errors/network'; + +/** + * Builds an address-free diagnostic suitable for telemetry or support reports. + * + * Per-item addresses, original inputs, raw errors, RPC URLs, headers, and + * credentials are deliberately excluded. + */ +export function buildComplianceBatchDiagnostic( + result: ComplianceBatchResult, +): ComplianceBatchDiagnostic { + const failureCodes: Partial> = {}; + let retryAfterSeconds: number | undefined; + + for (const item of result.items) { + if (item.status !== 'failed') { + continue; + } + + const code = item.diagnostic.code; + failureCodes[code] = (failureCodes[code] ?? 0) + 1; + if (item.diagnostic.retryAfterSeconds !== undefined) { + retryAfterSeconds = Math.max( + retryAfterSeconds ?? 0, + item.diagnostic.retryAfterSeconds, + ); + } + } + + const diagnostic = { + requested: result.summary.requested, + queried: result.summary.queried, + failed: result.summary.failed, + invalid: result.summary.invalid, + partial: result.summary.partial, + exhausted: result.summary.exhausted, + rateLimited: result.summary.rateLimited, + failureCodes: Object.freeze(failureCodes), + action: chooseRecoveryAction(result), + ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}), + }; + + return Object.freeze(diagnostic); +} + +function chooseRecoveryAction( + result: ComplianceBatchResult, +): ComplianceBatchRecoveryAction { + if (result.summary.rateLimited) { + return 'retry-with-backoff'; + } + + if (result.summary.failed > 0) { + return result.items.some( + (item) => item.status === 'failed' && item.diagnostic.retryable, + ) + ? 'retry-failed-items' + : 'report-unknown'; + } + + if (result.summary.invalid > 0) { + return 'review-invalid-input'; + } + + return 'none'; +} diff --git a/src/errors/compliance.ts b/src/errors/compliance.ts new file mode 100644 index 0000000..b2c87f9 --- /dev/null +++ b/src/errors/compliance.ts @@ -0,0 +1,15 @@ +export type ComplianceBatchErrorCode = + | 'INVALID_BATCH_INPUT' + | 'BATCH_TOO_LARGE' + | 'INVALID_BATCH_OPTIONS'; + +export class ComplianceBatchError extends Error { + public readonly code: ComplianceBatchErrorCode; + + constructor(code: ComplianceBatchErrorCode, message: string) { + super(message); + this.name = 'ComplianceBatchError'; + this.code = code; + Object.setPrototypeOf(this, ComplianceBatchError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index b498dcf..1f46b36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export type { export * from './types/client-factory'; export * from './errors/client-factory'; export { ComplianceModule } from './compliance'; +export { buildComplianceBatchDiagnostic } from './diagnostics/compliance'; export { AssetModule } from './asset'; export { InvestorModule } from './investor/portfolio'; export { RoleModule } from './role'; @@ -44,6 +45,8 @@ export { resolveClientConfig } from './config/validate'; export { AEGIS_ENVIRONMENTS, getEnvironmentPreset } from './config/environments'; export * from './types/portfolio'; export * from './errors/portfolio'; +export * from './types/compliance-batch'; +export * from './errors/compliance'; export * from './types/role'; export * from './errors/role'; export * from './types/admin-receipt'; diff --git a/src/testing/mock-client.ts b/src/testing/mock-client.ts index 0eed265..ba9b33a 100644 --- a/src/testing/mock-client.ts +++ b/src/testing/mock-client.ts @@ -7,6 +7,11 @@ import { FetchPortfolioOptions, TransferEligibility, } from '../types/portfolio'; +import { + ComplianceBatchOptions, + ComplianceBatchResult, +} from '../types/compliance-batch'; +import { executeComplianceBatch } from '../compliance/batch'; import { MOCK_CONTRACT_ID, DEFAULT_MOCK_ASSET_METADATA, @@ -193,6 +198,17 @@ export class MockComplianceModule { } return this.client._isWhitelisted(address); } + + public async checkWhitelistBatch( + addresses: readonly string[], + options: ComplianceBatchOptions = {}, + ): Promise { + return executeComplianceBatch( + addresses, + (address) => this.checkWhitelist(address), + options, + ); + } } export class MockAssetModule { diff --git a/src/types/compliance-batch.ts b/src/types/compliance-batch.ts new file mode 100644 index 0000000..d647a24 --- /dev/null +++ b/src/types/compliance-batch.ts @@ -0,0 +1,117 @@ +import { NetworkFailureDiagnostic } from '../diagnostics/network'; +import { NetworkFailureCode } from '../errors/network'; + +export type ComplianceBatchItemStatus = + | 'whitelisted' + | 'not-whitelisted' + | 'invalid-address' + | 'failed'; + +export type ComplianceBatchItemCode = + | 'OK' + | 'INVALID_ADDRESS' + | 'MUXED_ADDRESS_UNSUPPORTED' + | 'CONTRACT_ADDRESS_UNSUPPORTED' + | 'COMPLIANCE_QUERY_FAILED'; + +interface ComplianceBatchItemBase { + /** Original zero-based position, used to correlate with the caller's input. */ + readonly index: number; + readonly status: ComplianceBatchItemStatus; + readonly code: ComplianceBatchItemCode; + readonly isWhitelisted: boolean; + /** True when this item reused another identical address's RPC result. */ + readonly duplicate: boolean; + /** Fixed, safe UI copy. Never contains raw input or provider error text. */ + readonly message: string; +} + +export interface ComplianceBatchResolvedItem + extends ComplianceBatchItemBase { + readonly status: 'whitelisted' | 'not-whitelisted'; + readonly code: 'OK'; + /** Present only after an address passes Stellar account validation. */ + readonly address: string; +} + +export interface ComplianceBatchInvalidItem extends ComplianceBatchItemBase { + readonly status: 'invalid-address'; + readonly code: + | 'INVALID_ADDRESS' + | 'MUXED_ADDRESS_UNSUPPORTED' + | 'CONTRACT_ADDRESS_UNSUPPORTED'; + readonly isWhitelisted: false; + /** + * Invalid input is deliberately omitted. Use `index` to correlate with the + * caller's array without copying arbitrary input into logs or diagnostics. + */ + readonly address?: never; +} + +export interface ComplianceBatchFailedItem extends ComplianceBatchItemBase { + readonly status: 'failed'; + readonly code: 'COMPLIANCE_QUERY_FAILED'; + readonly isWhitelisted: false; + /** Present only after an address passes Stellar account validation. */ + readonly address: string; + readonly diagnostic: NetworkFailureDiagnostic; +} + +export type ComplianceBatchItem = + | ComplianceBatchResolvedItem + | ComplianceBatchInvalidItem + | ComplianceBatchFailedItem; + +export interface ComplianceBatchSummary { + readonly requested: number; + /** Distinct valid addresses for which an RPC request was attempted. */ + readonly queried: number; + readonly whitelisted: number; + readonly notWhitelisted: number; + readonly invalid: number; + readonly failed: number; + readonly duplicates: number; + readonly partial: boolean; + readonly exhausted: boolean; + readonly rateLimited: boolean; + readonly durationMs: number; +} + +export interface ComplianceBatchResult { + /** Exactly one item per input, in input order. */ + readonly items: readonly ComplianceBatchItem[]; + readonly summary: ComplianceBatchSummary; + readonly fetchedAt: string; +} + +export interface ComplianceBatchOptions { + /** Maximum simultaneous RPC requests. Defaults to `4`; allowed range 1–20. */ + concurrency?: number; + /** Query repeated valid addresses once and fan out the result. Defaults true. */ + deduplicate?: boolean; + /** Maximum accepted input length. Defaults to `100`; allowed range 1–1000. */ + maxBatchSize?: number; +} + +export type ComplianceBatchRecoveryAction = + | 'none' + | 'retry-failed-items' + | 'retry-with-backoff' + | 'review-invalid-input' + | 'report-unknown'; + +/** + * Address-free, serialisable roll-up for logs, telemetry, and support reports. + */ +export interface ComplianceBatchDiagnostic { + readonly requested: number; + readonly queried: number; + readonly failed: number; + readonly invalid: number; + readonly partial: boolean; + readonly exhausted: boolean; + readonly rateLimited: boolean; + readonly failureCodes: Readonly>>; + readonly action: ComplianceBatchRecoveryAction; + readonly retryAfterSeconds?: number; +} diff --git a/tests/compliance-batch.test.ts b/tests/compliance-batch.test.ts new file mode 100644 index 0000000..29dc412 --- /dev/null +++ b/tests/compliance-batch.test.ts @@ -0,0 +1,328 @@ +import { Keypair, Networks, StrKey } from '@stellar/stellar-sdk'; +import { + AegisClient, + ComplianceBatchError, + buildComplianceBatchDiagnostic, +} from '../src'; + +const ADDRESS_A = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)).publicKey(); +const ADDRESS_B = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey(); +const ADDRESS_C = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)).publicKey(); +const CONTRACT_ADDRESS = StrKey.encodeContract(Buffer.alloc(32, 4)); +const MUXED_ADDRESS = StrKey.encodeMed25519PublicKey(Buffer.alloc(40, 5)); + +describe('ComplianceModule.checkWhitelistBatch', () => { + let client: AegisClient; + let queryWhitelist: jest.SpyInstance, [string]>; + + beforeEach(() => { + client = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: CONTRACT_ADDRESS, + }); + queryWhitelist = jest.spyOn( + client.compliance as unknown as { + checkWhitelistForBatch(address: string): Promise; + }, + 'checkWhitelistForBatch', + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns typed results in input order for mixed valid and invalid input', async () => { + queryWhitelist.mockImplementation(async (address) => address === ADDRESS_A); + + const result = await client.compliance.checkWhitelistBatch([ + ADDRESS_A, + '', + ADDRESS_B, + 'not-a-stellar-address', + MUXED_ADDRESS, + CONTRACT_ADDRESS, + ]); + + expect(result.items.map((item) => [item.index, item.status, item.code])).toEqual([ + [0, 'whitelisted', 'OK'], + [1, 'invalid-address', 'INVALID_ADDRESS'], + [2, 'not-whitelisted', 'OK'], + [3, 'invalid-address', 'INVALID_ADDRESS'], + [4, 'invalid-address', 'MUXED_ADDRESS_UNSUPPORTED'], + [5, 'invalid-address', 'CONTRACT_ADDRESS_UNSUPPORTED'], + ]); + expect(result.summary).toMatchObject({ + requested: 6, + queried: 2, + whitelisted: 1, + notWhitelisted: 1, + invalid: 4, + failed: 0, + partial: false, + exhausted: false, + }); + expect(queryWhitelist).toHaveBeenCalledTimes(2); + }); + + it('omits arbitrary invalid input from serialised results', async () => { + const result = await client.compliance.checkWhitelistBatch([ + 'Bearer secret-token', + ]); + + expect(result.items[0]).toEqual({ + index: 0, + status: 'invalid-address', + code: 'INVALID_ADDRESS', + isWhitelisted: false, + duplicate: false, + message: 'Address is not a valid Stellar account public key.', + }); + expect(JSON.stringify(result)).not.toContain('secret-token'); + expect(queryWhitelist).not.toHaveBeenCalled(); + }); + + it('represents partial failures without rejecting successful items', async () => { + queryWhitelist.mockImplementation(async (address) => { + if (address === ADDRESS_B) { + throw Object.assign( + new Error('https://rpc.example/?token=secret-value timed out'), + { code: 'ETIMEDOUT' }, + ); + } + return true; + }); + + const result = await client.compliance.checkWhitelistBatch([ + ADDRESS_A, + ADDRESS_B, + ADDRESS_C, + ]); + + expect(result.items[0].status).toBe('whitelisted'); + expect(result.items[1]).toMatchObject({ + status: 'failed', + code: 'COMPLIANCE_QUERY_FAILED', + isWhitelisted: false, + diagnostic: { + code: 'TIMEOUT', + retryable: true, + action: 'retry', + }, + }); + expect(result.items[2].status).toBe('whitelisted'); + expect(result.summary).toMatchObject({ + whitelisted: 2, + failed: 1, + partial: true, + exhausted: false, + }); + expect(JSON.stringify(result)).not.toContain('secret-value'); + expect(JSON.stringify(result)).not.toContain('rpc.example'); + }); + + it('marks the batch exhausted when every valid query fails', async () => { + queryWhitelist.mockRejectedValue(new Error('unknown private failure')); + + const result = await client.compliance.checkWhitelistBatch([ + ADDRESS_A, + ADDRESS_B, + ]); + + expect(result.summary).toMatchObject({ + queried: 2, + failed: 2, + partial: false, + exhausted: true, + }); + }); + + it('deduplicates valid addresses while preserving one item per input', async () => { + queryWhitelist.mockResolvedValue(true); + + const result = await client.compliance.checkWhitelistBatch([ + ADDRESS_A, + ADDRESS_B, + ADDRESS_A, + ADDRESS_A, + ]); + + expect(queryWhitelist).toHaveBeenCalledTimes(2); + expect(result.items).toHaveLength(4); + expect(result.items.map((item) => item.index)).toEqual([0, 1, 2, 3]); + expect(result.items.map((item) => item.duplicate)).toEqual([ + false, + false, + true, + true, + ]); + expect(result.summary).toMatchObject({ queried: 2, duplicates: 2 }); + }); + + it('can disable deduplication explicitly', async () => { + queryWhitelist.mockResolvedValue(false); + + const result = await client.compliance.checkWhitelistBatch( + [ADDRESS_A, ADDRESS_A], + { deduplicate: false }, + ); + + expect(queryWhitelist).toHaveBeenCalledTimes(2); + expect(result.summary).toMatchObject({ queried: 2, duplicates: 0 }); + }); + + it('respects the configured concurrency bound', async () => { + let inFlight = 0; + let peakInFlight = 0; + queryWhitelist.mockImplementation(async () => { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return true; + }); + + await client.compliance.checkWhitelistBatch( + [ADDRESS_A, ADDRESS_B, ADDRESS_C], + { concurrency: 2 }, + ); + + expect(peakInFlight).toBe(2); + }); + + it('returns an empty frozen result for an empty batch', async () => { + const result = await client.compliance.checkWhitelistBatch([]); + + expect(result.items).toEqual([]); + expect(result.summary).toMatchObject({ + requested: 0, + queried: 0, + invalid: 0, + failed: 0, + partial: false, + exhausted: false, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.items)).toBe(true); + expect(Object.isFrozen(result.summary)).toBe(true); + }); + + it('validates batch size and options before querying', async () => { + await expect( + client.compliance.checkWhitelistBatch([ADDRESS_A, ADDRESS_B], { + maxBatchSize: 1, + }), + ).rejects.toMatchObject({ + name: 'ComplianceBatchError', + code: 'BATCH_TOO_LARGE', + }); + + await expect( + client.compliance.checkWhitelistBatch([ADDRESS_A], { concurrency: 0 }), + ).rejects.toBeInstanceOf(ComplianceBatchError); + await expect( + client.compliance.checkWhitelistBatch([ADDRESS_A], { concurrency: 21 }), + ).rejects.toMatchObject({ code: 'INVALID_BATCH_OPTIONS' }); + expect(queryWhitelist).not.toHaveBeenCalled(); + }); + + it('does not misreport an unsuccessful simulation as not whitelisted', async () => { + queryWhitelist.mockRestore(); + jest + .spyOn(client.rpcServer, 'simulateTransaction') + .mockResolvedValue({ error: 'contract simulation failed' } as never); + + const result = await client.compliance.checkWhitelistBatch([ADDRESS_A]); + + expect(result.items[0]).toMatchObject({ + status: 'failed', + code: 'COMPLIANCE_QUERY_FAILED', + diagnostic: { + code: 'MALFORMED_RESPONSE', + retryable: false, + }, + }); + expect(result.summary.notWhitelisted).toBe(0); + expect(result.summary.failed).toBe(1); + }); + + it('classifies malformed response failures safely per item', async () => { + queryWhitelist.mockRejectedValue(new Error('XDR Parsing failed.')); + + const result = await client.compliance.checkWhitelistBatch([ADDRESS_A]); + const item = result.items[0]; + + expect(item).toMatchObject({ + status: 'failed', + diagnostic: { + code: 'MALFORMED_RESPONSE', + retryable: false, + }, + }); + }); +}); + +describe('buildComplianceBatchDiagnostic', () => { + it('produces an address-free rate-limit roll-up with retry guidance', async () => { + const client = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: CONTRACT_ADDRESS, + }); + jest.spyOn( + client.compliance as unknown as { + checkWhitelistForBatch(address: string): Promise; + }, + 'checkWhitelistForBatch', + ).mockImplementation(async (address) => { + if (address === ADDRESS_B) { + throw { + response: { status: 429 }, + retryAfterSeconds: 2.2, + message: 'Too many requests; authorization=secret-token', + }; + } + return true; + }); + + const result = await client.compliance.checkWhitelistBatch([ + ADDRESS_A, + ADDRESS_B, + 'private-invalid-input', + ]); + const diagnostic = buildComplianceBatchDiagnostic(result); + + expect(diagnostic).toEqual({ + requested: 3, + queried: 2, + failed: 1, + invalid: 1, + partial: true, + exhausted: false, + rateLimited: true, + failureCodes: { RATE_LIMITED: 1 }, + action: 'retry-with-backoff', + retryAfterSeconds: 3, + }); + expect(Object.isFrozen(diagnostic)).toBe(true); + expect(Object.isFrozen(diagnostic.failureCodes)).toBe(true); + expect(JSON.stringify(diagnostic)).not.toContain(ADDRESS_A); + expect(JSON.stringify(diagnostic)).not.toContain(ADDRESS_B); + expect(JSON.stringify(diagnostic)).not.toContain('private-invalid-input'); + expect(JSON.stringify(diagnostic)).not.toContain('secret-token'); + }); + + it('recommends reviewing invalid input when no RPC query fails', async () => { + const client = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: CONTRACT_ADDRESS, + }); + const result = await client.compliance.checkWhitelistBatch(['invalid']); + + expect(buildComplianceBatchDiagnostic(result).action).toBe( + 'review-invalid-input', + ); + }); +}); diff --git a/tests/mock-client.test.ts b/tests/mock-client.test.ts index 6a3f222..b58ad80 100644 --- a/tests/mock-client.test.ts +++ b/tests/mock-client.test.ts @@ -22,6 +22,30 @@ describe('MockAegisClient', () => { ).resolves.toBe(false); }); + it('supports deterministic batch compliance queries', async () => { + const fixtures = createMockFixtures(); + const client = createMockAegisClient(); + client.setWhitelisted(fixtures.investorAddress, true); + + const result = await client.compliance.checkWhitelistBatch([ + fixtures.investorAddress, + 'invalid input must stay per-item', + fixtures.secondaryInvestorAddress, + ]); + + expect(result.items.map((item) => item.status)).toEqual([ + 'whitelisted', + 'invalid-address', + 'not-whitelisted', + ]); + expect(result.summary).toMatchObject({ + requested: 3, + queried: 2, + invalid: 1, + failed: 0, + }); + }); + it('throws when compliance failure simulation is enabled', async () => { const fixtures = createMockFixtures(); const client = createMockAegisClient({ simulateComplianceFailure: true });