diff --git a/README.md b/README.md index 4d2bb5a..1cb00d1 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,17 @@ const aegis = new AegisClient({ }); ``` +## Compliance Audit Reports + +Build deterministic, typed summaries from caller-supplied protocol evidence with +`client.compliance.buildAuditReport(...)` or the standalone +`buildComplianceAuditReport(...)` export. The builder performs no network I/O +and does not make legal or financial compliance determinations. + +See [Compliance Audit Report Builder](./docs/compliance-audit-report.md) for the +data model, stable validation errors, evidence requirements, ordering rules, and +security/compliance boundary. + ## 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/api-reference.md b/docs/api-reference.md index e047534..cf7e83e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -29,6 +29,20 @@ Either `environment` or both `rpcUrl` and `networkPassphrase` must be provided. Whitelist / KYC verification module. Accessed via `client.compliance`. Wraps the contract's `is_whitelisted` read-only function. +### `buildAuditReport(input: ComplianceAuditInput): ComplianceAuditReport` + +Builds a deterministic schema-v1 report from caller-supplied evidence and check +statuses. This method is pure: it performs no RPC calls and delegates to the +standalone `buildComplianceAuditReport()` export. + +Non-`unknown` findings require at least one known evidence id. Invalid or +ambiguous input throws `ComplianceAuditInputError` with a stable error code. +Overall status precedence is `fail > unknown > warn > pass`. + +See [Compliance Audit Report Builder](./compliance-audit-report.md) for the data +model, validation table, deterministic ordering, examples, and the +security/compliance boundary. The report is not legal or financial advice. + ### `checkWhitelist(address: string): Promise` Queries the contract to check if a user is KYC-approved (whitelisted). diff --git a/docs/compliance-audit-report.md b/docs/compliance-audit-report.md new file mode 100644 index 0000000..50ac4ab --- /dev/null +++ b/docs/compliance-audit-report.md @@ -0,0 +1,85 @@ +# Compliance audit report builder + +The SDK provides a deterministic report builder for **caller-supplied protocol evidence**. It is available as the standalone `buildComplianceAuditReport()` helper and as `client.compliance.buildAuditReport()`. + +The builder performs no RPC calls, does not discover evidence, and does not decide whether an entity is legally or financially compliant. Its output is a structured summary of the evidence and statuses supplied by the caller. + +## Example + +```typescript +import { AegisClient } from '@aegis/sdk'; + +const report = client.compliance.buildAuditReport({ + subject: 'asset-42', + asOf: new Date().toISOString(), + evidence: [ + { + id: 'kyc-ledger', + source: 'protocol', + description: 'Current protocol whitelist observation', + reference: 'ledger:123456', + }, + ], + checks: [ + { + code: 'KYC_STATE', + summary: 'Protocol whitelist observation is present', + status: 'pass', + evidenceIds: ['kyc-ledger'], + }, + ], +}); + +console.log(report.overallStatus); // pass +``` + +The same operation is available without constructing a client: + +```typescript +import { buildComplianceAuditReport } from '@aegis/sdk'; +``` + +## Deterministic output + +Reports use schema version `1`. + +- findings are sorted by `code`; +- evidence is sorted by `id`; +- duplicate evidence references inside a finding are removed and sorted; +- summaries count `pass`, `warn`, `fail`, and `unknown`; +- overall precedence is `fail > unknown > warn > pass`. + +The builder does not read the network or the local filesystem. The caller is responsible for obtaining evidence and choosing each check status. + +## Validation and failure states + +Invalid input throws `ComplianceAuditInputError` with a stable `code`: + +| Code | Meaning | +| --- | --- | +| `EMPTY_SUBJECT` | Report subject is blank. | +| `EMPTY_CHECKS` | No checks were supplied. | +| `EMPTY_IDENTIFIER` | A check code or evidence id is blank. | +| `DUPLICATE_CHECK_CODE` | Two checks use the same normalized code. | +| `DUPLICATE_EVIDENCE_ID` | Two evidence records use the same normalized id. | +| `UNKNOWN_EVIDENCE_REFERENCE` | A finding references an evidence id not present in the input. | +| `EVIDENCE_REQUIRED` | A `pass`, `warn`, or `fail` finding has no evidence reference. | +| `INVALID_STATUS` | A runtime check status is outside `pass`, `warn`, `fail`, or `unknown`. | +| `INVALID_EVIDENCE_SOURCE` | A runtime evidence source is outside `protocol`, `sdk`, or `operator`. | +| `INVALID_REPORT_INPUT` | A runtime report, list, record, text field, or evidence-id list has the wrong shape or type. | + +An `unknown` finding may intentionally have no evidence. That state represents an unresolved observation rather than an evidenced conclusion. + +## Security and compliance boundary + +Treat the builder as a formatting and normalization boundary, not an authority: + +- runtime report shapes are validated before normalization; malformed arrays, records, and text fields fail with `INVALID_REPORT_INPUT` rather than leaking native exceptions; +- report evidence emits only the declared `id`, `source`, `description`, and optional `reference` fields; undeclared caller properties are not copied into exported reports; +- evidence is accepted as caller-supplied data and is not independently verified; +- a `pass` status does not establish legal, financial, regulatory, KYC, AML, or transfer eligibility; +- a `fail` status does not replace protocol enforcement or professional review; +- consumers should preserve authoritative evidence references so dashboards and audit trails can trace each finding back to its source; +- callers should avoid placing secrets, private keys, credentials, or unnecessary personal data in evidence descriptions or references. + +Every report includes the SDK disclaimer: the report summarizes caller-supplied protocol evidence and is not legal or financial advice. diff --git a/src/compliance-audit.ts b/src/compliance-audit.ts new file mode 100644 index 0000000..8f40158 --- /dev/null +++ b/src/compliance-audit.ts @@ -0,0 +1,306 @@ +export type ComplianceAuditStatus = 'pass' | 'warn' | 'fail' | 'unknown'; + +export type ComplianceAuditEvidenceSource = 'protocol' | 'sdk' | 'operator'; + +export interface ComplianceAuditEvidence { + id: string; + source: ComplianceAuditEvidenceSource; + description: string; + reference?: string; +} + +export interface ComplianceAuditCheckInput { + code: string; + summary: string; + status: ComplianceAuditStatus; + evidenceIds?: readonly string[]; +} + +export interface ComplianceAuditInput { + subject: string; + asOf?: string; + evidence: readonly ComplianceAuditEvidence[]; + checks: readonly ComplianceAuditCheckInput[]; +} + +export interface ComplianceAuditSummary { + total: number; + pass: number; + warn: number; + fail: number; + unknown: number; +} + +export interface ComplianceAuditFinding { + code: string; + summary: string; + status: ComplianceAuditStatus; + evidenceIds: readonly string[]; +} + +export interface ComplianceAuditReport { + schemaVersion: 1; + subject: string; + asOf?: string; + overallStatus: ComplianceAuditStatus; + summary: ComplianceAuditSummary; + findings: readonly ComplianceAuditFinding[]; + evidence: readonly ComplianceAuditEvidence[]; + disclaimer: string; +} + +export type ComplianceAuditInputErrorCode = + | 'EMPTY_SUBJECT' + | 'EMPTY_CHECKS' + | 'EMPTY_IDENTIFIER' + | 'DUPLICATE_CHECK_CODE' + | 'DUPLICATE_EVIDENCE_ID' + | 'UNKNOWN_EVIDENCE_REFERENCE' + | 'EVIDENCE_REQUIRED' + | 'INVALID_STATUS' + | 'INVALID_EVIDENCE_SOURCE' + | 'INVALID_REPORT_INPUT'; + +export class ComplianceAuditInputError extends Error { + public readonly code: ComplianceAuditInputErrorCode; + + constructor(code: ComplianceAuditInputErrorCode, message: string) { + super(message); + this.name = 'ComplianceAuditInputError'; + this.code = code; + } +} + +export const COMPLIANCE_AUDIT_DISCLAIMER = + 'This report summarizes caller-supplied protocol evidence and is not legal or financial advice.'; + +const STATUS_RANK: Record = { + pass: 0, + warn: 1, + unknown: 2, + fail: 3, +}; + +const EVIDENCE_SOURCES: Record = { + protocol: true, + sdk: true, + operator: true, +}; + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function invalidReportInput(field: string): never { + throw new ComplianceAuditInputError( + 'INVALID_REPORT_INPUT', + field + ' has an invalid runtime shape or type' + ); +} + +function requireRecord(value: unknown, field: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return invalidReportInput(field); + } + return value as Record; +} + +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') { + return invalidReportInput(field); + } + return value.trim(); +} + +function requireOptionalText(value: unknown, field: string): string | undefined { + if (value === undefined) return undefined; + const normalized = requireText(value, field); + return normalized || undefined; +} + +function requireIdentifier(value: unknown, field: string): string { + const normalized = requireText(value, field); + if (!normalized) { + throw new ComplianceAuditInputError( + 'EMPTY_IDENTIFIER', + field + ' must be a non-empty identifier' + ); + } + return normalized; +} + +function requireStatus(value: unknown, field: string): ComplianceAuditStatus { + if (typeof value !== 'string') { + return invalidReportInput(field); + } + if (!Object.prototype.hasOwnProperty.call(STATUS_RANK, value)) { + throw new ComplianceAuditInputError( + 'INVALID_STATUS', + field + ' must be one of pass, warn, fail, unknown' + ); + } + return value as ComplianceAuditStatus; +} + +function requireEvidenceSource( + value: unknown, + field: string +): ComplianceAuditEvidenceSource { + if (typeof value !== 'string') { + return invalidReportInput(field); + } + if (!Object.prototype.hasOwnProperty.call(EVIDENCE_SOURCES, value)) { + throw new ComplianceAuditInputError( + 'INVALID_EVIDENCE_SOURCE', + field + ' must be one of protocol, sdk, operator' + ); + } + return value as ComplianceAuditEvidenceSource; +} + +function normalizeEvidence(evidence: unknown): ComplianceAuditEvidence[] { + if (!Array.isArray(evidence)) { + return invalidReportInput('evidence'); + } + + const seen = new Set(); + + return Array.from(evidence) + .map((value, index) => { + const item = requireRecord(value, 'evidence[' + index + ']'); + const id = requireIdentifier(item.id, 'evidence[' + index + '].id'); + if (seen.has(id)) { + throw new ComplianceAuditInputError( + 'DUPLICATE_EVIDENCE_ID', + 'duplicate evidence id: ' + id + ); + } + seen.add(id); + + return { + id, + source: requireEvidenceSource(item.source, 'evidence[' + index + '].source'), + description: requireText(item.description, 'evidence[' + index + '].description'), + reference: requireOptionalText(item.reference, 'evidence[' + index + '].reference'), + }; + }) + .sort((left, right) => compareText(left.id, right.id)); +} + +function normalizeFindings( + checks: unknown, + evidenceIds: ReadonlySet +): ComplianceAuditFinding[] { + if (!Array.isArray(checks)) { + return invalidReportInput('checks'); + } + if (checks.length === 0) { + throw new ComplianceAuditInputError( + 'EMPTY_CHECKS', + 'at least one compliance check is required' + ); + } + + const seenCodes = new Set(); + + return Array.from(checks) + .map((value, index) => { + const check = requireRecord(value, 'checks[' + index + ']'); + const code = requireIdentifier(check.code, 'checks[' + index + '].code'); + const status = requireStatus(check.status, code + '.status'); + if (seenCodes.has(code)) { + throw new ComplianceAuditInputError( + 'DUPLICATE_CHECK_CODE', + 'duplicate check code: ' + code + ); + } + seenCodes.add(code); + + const rawEvidenceIds = check.evidenceIds; + if (rawEvidenceIds !== undefined && !Array.isArray(rawEvidenceIds)) { + return invalidReportInput(code + '.evidenceIds'); + } + const refs = Array.from( + new Set( + Array.from(rawEvidenceIds ?? []).map((id) => + requireIdentifier(id, code + '.evidenceId') + ) + ) + ).sort(compareText); + + for (const evidenceId of refs) { + if (!evidenceIds.has(evidenceId)) { + throw new ComplianceAuditInputError( + 'UNKNOWN_EVIDENCE_REFERENCE', + code + ' references unknown evidence id: ' + evidenceId + ); + } + } + + if (status !== 'unknown' && refs.length === 0) { + throw new ComplianceAuditInputError( + 'EVIDENCE_REQUIRED', + code + ' requires evidence for status ' + status + ); + } + + return { + code, + summary: requireText(check.summary, code + '.summary'), + status, + evidenceIds: refs, + }; + }) + .sort((left, right) => compareText(left.code, right.code)); +} + +function summarize(findings: readonly ComplianceAuditFinding[]): ComplianceAuditSummary { + const summary: ComplianceAuditSummary = { + total: findings.length, + pass: 0, + warn: 0, + fail: 0, + unknown: 0, + }; + + for (const finding of findings) { + summary[finding.status] += 1; + } + + return summary; +} + +function overallStatus(findings: readonly ComplianceAuditFinding[]): ComplianceAuditStatus { + let overall: ComplianceAuditStatus = 'pass'; + + for (const finding of findings) { + if (STATUS_RANK[finding.status] > STATUS_RANK[overall]) { + overall = finding.status; + } + } + + return overall; +} + +export function buildComplianceAuditReport(input: ComplianceAuditInput): ComplianceAuditReport { + const reportInput = requireRecord(input, 'report'); + const subject = requireText(reportInput.subject, 'subject'); + if (!subject) { + throw new ComplianceAuditInputError('EMPTY_SUBJECT', 'subject must be non-empty'); + } + + const evidence = normalizeEvidence(reportInput.evidence); + const evidenceIds = new Set(evidence.map((item) => item.id)); + const findings = normalizeFindings(reportInput.checks, evidenceIds); + + return { + schemaVersion: 1, + subject, + asOf: requireOptionalText(reportInput.asOf, 'asOf'), + overallStatus: overallStatus(findings), + summary: summarize(findings), + findings, + evidence, + disclaimer: COMPLIANCE_AUDIT_DISCLAIMER, + }; +} diff --git a/src/compliance.ts b/src/compliance.ts index 04e0be7..5a46958 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,6 +1,11 @@ import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; import { parseSorobanResult } from './utils/xdr-parser'; +import { + buildComplianceAuditReport, + ComplianceAuditInput, + ComplianceAuditReport, +} from './compliance-audit'; export class ComplianceModule { private client: AegisClient; @@ -9,6 +14,17 @@ constructor(client: AegisClient) { this.client = client; } + /** + * Builds a deterministic compliance audit report from caller-supplied evidence. + * + * This helper performs no RPC calls and does not make legal or financial + * compliance determinations; it only normalizes and summarizes the supplied + * protocol evidence. + */ + public buildAuditReport(input: ComplianceAuditInput): ComplianceAuditReport { + return buildComplianceAuditReport(input); + } + /** * Queries the contract to check if a user is KYC-approved (whitelisted). * @param address The Stellar public key to check. diff --git a/src/index.ts b/src/index.ts index b498dcf..cd28791 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 * from './compliance-audit'; export { AssetModule } from './asset'; export { InvestorModule } from './investor/portfolio'; export { RoleModule } from './role'; diff --git a/tests/compliance-audit.test.ts b/tests/compliance-audit.test.ts new file mode 100644 index 0000000..76c917f --- /dev/null +++ b/tests/compliance-audit.test.ts @@ -0,0 +1,290 @@ +import { + COMPLIANCE_AUDIT_DISCLAIMER, + ComplianceAuditInputError, + ComplianceModule, + buildComplianceAuditReport, +} from '../src'; +import type { AegisClient } from '../src/client'; + +describe('compliance audit reports', () => { + it('builds a deterministic report with fail-closed status precedence', () => { + const report = buildComplianceAuditReport({ + subject: ' portfolio-42 ', + asOf: ' 2026-10-06T05:00:00Z ', + evidence: [ + { + id: 'kyc-state', + source: 'protocol', + description: 'On-chain KYC state', + reference: ' ledger:42 ', + }, + { + id: 'asset-policy', + source: 'operator', + description: 'Approved asset policy', + }, + ], + checks: [ + { + code: 'KYC', + summary: 'KYC state is unavailable', + status: 'unknown', + }, + { + code: 'ASSET', + summary: 'Asset policy matches', + status: 'pass', + evidenceIds: ['asset-policy'], + }, + { + code: 'BLOCK', + summary: 'Protocol state blocks the operation', + status: 'fail', + evidenceIds: ['kyc-state', 'asset-policy', 'kyc-state'], + }, + ], + }); + + expect(report).toEqual({ + schemaVersion: 1, + subject: 'portfolio-42', + asOf: '2026-10-06T05:00:00Z', + overallStatus: 'fail', + summary: { + total: 3, + pass: 1, + warn: 0, + fail: 1, + unknown: 1, + }, + findings: [ + { + code: 'ASSET', + summary: 'Asset policy matches', + status: 'pass', + evidenceIds: ['asset-policy'], + }, + { + code: 'BLOCK', + summary: 'Protocol state blocks the operation', + status: 'fail', + evidenceIds: ['asset-policy', 'kyc-state'], + }, + { + code: 'KYC', + summary: 'KYC state is unavailable', + status: 'unknown', + evidenceIds: [], + }, + ], + evidence: [ + { + id: 'asset-policy', + source: 'operator', + description: 'Approved asset policy', + reference: undefined, + }, + { + id: 'kyc-state', + source: 'protocol', + description: 'On-chain KYC state', + reference: 'ledger:42', + }, + ], + disclaimer: COMPLIANCE_AUDIT_DISCLAIMER, + }); + }); + + it('rejects unknown evidence references and evidenced statuses without evidence', () => { + expect(() => + buildComplianceAuditReport({ + subject: 'portfolio-42', + evidence: [], + checks: [ + { + code: 'KYC', + summary: 'KYC state passes', + status: 'pass', + evidenceIds: ['missing'], + }, + ], + }), + ).toThrow( + expect.objectContaining>({ + code: 'UNKNOWN_EVIDENCE_REFERENCE', + }), + ); + + expect(() => + buildComplianceAuditReport({ + subject: 'portfolio-42', + evidence: [], + checks: [ + { + code: 'KYC', + summary: 'KYC state passes', + status: 'pass', + }, + ], + }), + ).toThrow( + expect.objectContaining>({ + code: 'EVIDENCE_REQUIRED', + }), + ); + }); + + it('rejects unsupported runtime status values before summarizing', () => { + expect(() => + buildComplianceAuditReport({ + subject: 'portfolio-42', + evidence: [], + checks: [ + { + code: 'KYC', + summary: 'Unexpected external status', + status: 'skipped' as never, + }, + ], + }), + ).toThrow( + expect.objectContaining>({ + code: 'INVALID_STATUS', + }), + ); + }); + + it('rejects unsupported runtime evidence sources before building a report', () => { + expect(() => + buildComplianceAuditReport({ + subject: 'portfolio-42', + evidence: [ + { + id: 'kyc-state', + source: 'external-registry' as never, + description: 'Runtime source outside the typed SDK contract', + }, + ], + checks: [ + { + code: 'KYC', + summary: 'KYC state is unresolved', + status: 'unknown', + evidenceIds: ['kyc-state'], + }, + ], + }), + ).toThrow( + expect.objectContaining>({ + code: 'INVALID_EVIDENCE_SOURCE', + }), + ); + }); + + it('rejects malformed runtime report input and strips undeclared evidence fields', () => { + const sparseEvidence = new Array(1); + const sparseChecks = new Array(1); + const malformedInputs = [ + { + subject: 'portfolio-42', + evidence: sparseEvidence as never, + checks: [{ code: 'KYC', summary: 'Pending', status: 'unknown' as const }], + }, + { + subject: 'portfolio-42', + evidence: [], + checks: sparseChecks as never, + }, + { + subject: 42 as never, + evidence: [], + checks: [{ code: 'KYC', summary: 'Pending', status: 'unknown' as const }], + }, + { + subject: 'portfolio-42', + evidence: null as never, + checks: [{ code: 'KYC', summary: 'Pending', status: 'unknown' as const }], + }, + { + subject: 'portfolio-42', + evidence: [], + checks: [{ code: 'KYC', summary: { private: 'value' } as never, status: 'unknown' as const }], + }, + { + subject: 'portfolio-42', + evidence: [], + checks: [{ + code: 'KYC', + summary: 'Pending', + status: 'unknown' as const, + evidenceIds: 'kyc-state' as never, + }], + }, + { + subject: 'portfolio-42', + evidence: [], + checks: [{ + code: 'KYC', + summary: 'Pending', + status: 'unknown' as const, + evidenceIds: new Array(1) as never, + }], + }, + ]; + + for (const input of malformedInputs) { + expect(() => buildComplianceAuditReport(input as never)).toThrow( + expect.objectContaining>({ + code: 'INVALID_REPORT_INPUT', + }), + ); + } + + const report = buildComplianceAuditReport({ + subject: 'portfolio-42', + evidence: [ + { + id: 'kyc-state', + source: 'protocol', + description: 'Protocol state', + privateNote: 'must not be published', + } as never, + ], + checks: [ + { + code: 'KYC', + summary: 'Protocol state is unresolved', + status: 'unknown', + evidenceIds: ['kyc-state'], + }, + ], + }); + + expect(report.evidence[0]).toEqual({ + id: 'kyc-state', + source: 'protocol', + description: 'Protocol state', + reference: undefined, + }); + expect(report.evidence[0]).not.toHaveProperty('privateNote'); + }); + + it('exposes the same pure builder through ComplianceModule', () => { + const module = new ComplianceModule({} as AegisClient); + const report = module.buildAuditReport({ + subject: 'portfolio-42', + evidence: [], + checks: [ + { + code: 'MANUAL_REVIEW', + summary: 'Operator review is pending', + status: 'unknown', + }, + ], + }); + + expect(report.overallStatus).toBe('unknown'); + expect(report.summary.unknown).toBe(1); + expect(report.disclaimer).toBe(COMPLIANCE_AUDIT_DISCLAIMER); + }); +});