diff --git a/packages/compliance-core/package.json b/packages/compliance-core/package.json new file mode 100644 index 0000000..77c79bb --- /dev/null +++ b/packages/compliance-core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@crypto-wallet/compliance-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": {}, + "devDependencies": { + "vitest": "^4.1.11" + } +} diff --git a/packages/compliance-core/src/__tests__/compliance-core.test.ts b/packages/compliance-core/src/__tests__/compliance-core.test.ts new file mode 100644 index 0000000..fc31c0a --- /dev/null +++ b/packages/compliance-core/src/__tests__/compliance-core.test.ts @@ -0,0 +1,516 @@ +import { describe, expect, it } from "vitest"; + +import { ScreeningService, TransactionMonitoringService } from "../screening.js"; + +import { calculateRiskAssessment } from "../risk-scoring.js"; + +import { validateTravelRuleTransfer } from "../travel-rule.js"; + +import { ComplianceCaseManager } from "../case-management.js"; + +describe("ScreeningService", () => { + it("registers and executes a screening provider", async () => { + const service = new ScreeningService(); + + service.register("sanctions", { + name: "test-provider", + async screen() { + return { + screeningType: "sanctions", + status: "clear", + provider: "test-provider", + providerReference: "screen-001", + screenedAt: "2026-09-15T10:00:00Z", + }; + }, + }); + + const result = await service.screen({ + subjectId: "subject-1", + screeningType: "sanctions", + reference: "request-001", + }); + + expect(result.status).toBe("clear"); + expect(result.provider).toBe("test-provider"); + }); + + it("rejects duplicate providers for the same screening type", () => { + const service = new ScreeningService(); + + const provider = { + name: "provider-a", + async screen() { + return { + screeningType: "kyc" as const, + status: "clear" as const, + provider: "provider-a", + screenedAt: "2026-09-15T10:00:00Z", + }; + }, + }; + + service.register("kyc", provider); + + expect(() => service.register("kyc", provider)).toThrow( + "Screening provider already registered for kyc", + ); + }); + + it("rejects screening when no provider is registered", async () => { + const service = new ScreeningService(); + + await expect( + service.screen({ + subjectId: "subject-1", + screeningType: "pep", + reference: "request-001", + }), + ).rejects.toThrow("No screening provider registered for pep"); + }); + + it("rejects an invalid screening observation", async () => { + const service = new ScreeningService(); + + service.register("sanctions", { + name: "test-provider", + async screen() { + return { + screeningType: "sanctions", + status: "clear", + provider: "", + screenedAt: "2026-09-15T10:00:00Z", + }; + }, + }); + + await expect( + service.screen({ + subjectId: "subject-1", + screeningType: "sanctions", + reference: "request-001", + }), + ).rejects.toThrow("Screening provider must not be empty"); + }); +}); + +describe("TransactionMonitoringService", () => { + it("evaluates all registered providers", async () => { + const service = new TransactionMonitoringService(); + + service.register({ + name: "rule-provider-a", + async evaluate() { + return { + status: "clear", + riskScore: 10, + ruleCodes: ["LOW_ACTIVITY"], + reason: "No suspicious activity detected", + }; + }, + }); + + service.register({ + name: "rule-provider-b", + async evaluate() { + return { + status: "review", + riskScore: 65, + ruleCodes: ["VELOCITY"], + reason: "High transaction velocity", + }; + }, + }); + + const results = await service.evaluate({ + subjectId: "subject-1", + transactionType: "withdrawal", + transactionId: "tx-1", + amount: "100000", + assetId: "asset-1", + occurredAt: "2026-09-15T10:00:00Z", + }); + + expect(results).toHaveLength(2); + expect(results[0]?.status).toBe("clear"); + expect(results[1]?.status).toBe("review"); + }); + + it("rejects transaction monitoring without providers", async () => { + const service = new TransactionMonitoringService(); + + await expect( + service.evaluate({ + transactionType: "withdrawal", + transactionId: "tx-1", + amount: "100", + assetId: "asset-1", + occurredAt: "2026-09-15T10:00:00Z", + }), + ).rejects.toThrow("No transaction monitoring provider registered"); + }); + + it("rejects a risk score outside 0-100", async () => { + const service = new TransactionMonitoringService(); + + service.register({ + name: "bad-provider", + async evaluate() { + return { + status: "review", + riskScore: 101, + ruleCodes: ["BAD_SCORE"], + reason: "Invalid score", + }; + }, + }); + + await expect( + service.evaluate({ + transactionType: "trade", + transactionId: "tx-1", + amount: "100", + assetId: "asset-1", + occurredAt: "2026-09-15T10:00:00Z", + }), + ).rejects.toThrow("Transaction monitoring risk score must be 0-100"); + }); +}); + +describe("calculateRiskAssessment", () => { + const safeInput = { + account: { + accountAgeDays: 365, + priorComplianceReviews: 0, + priorAlerts: 0, + }, + device: { + known: true, + recentDeviceChanges: 0, + failedAuthenticationAttempts: 0, + }, + withdrawal: { + amount: "1000", + recentWithdrawalCount: 0, + newDestination: false, + recentlyChangedSecuritySettings: false, + }, + transaction: { + amount: "1000", + velocityCount: 1, + velocityWindowMinutes: 60, + unusualAsset: false, + unusualNetwork: false, + }, + }; + + it("returns low risk for normal behavior", () => { + const result = calculateRiskAssessment(safeInput); + + expect(result.score).toBe(0); + expect(result.riskLevel).toBe("low"); + expect(result.factors).toHaveLength(0); + }); + + it("adds account and device risk factors", () => { + const result = calculateRiskAssessment({ + ...safeInput, + account: { + accountAgeDays: 2, + priorComplianceReviews: 2, + priorAlerts: 2, + }, + device: { + known: false, + recentDeviceChanges: 1, + failedAuthenticationAttempts: 2, + }, + }); + + expect(result.factors.map((factor) => factor.code)).toEqual([ + "NEW_ACCOUNT", + "PRIOR_COMPLIANCE_REVIEW", + "PRIOR_ALERTS", + "UNKNOWN_DEVICE", + "RECENT_DEVICE_CHANGE", + "FAILED_AUTHENTICATION", + ]); + + expect(result.score).toBe(66); + expect(result.riskLevel).toBe("high"); + }); + + it("adds withdrawal risk factors", () => { + const result = calculateRiskAssessment({ + ...safeInput, + withdrawal: { + amount: "50000", + recentWithdrawalCount: 5, + newDestination: true, + recentlyChangedSecuritySettings: true, + }, + }); + + expect(result.factors.map((factor) => factor.code)).toEqual([ + "WITHDRAWAL_VELOCITY", + "NEW_DESTINATION", + "RECENT_SECURITY_CHANGE", + ]); + + expect(result.score).toBe(60); + expect(result.riskLevel).toBe("high"); + }); + + it("adds transaction risk factors", () => { + const result = calculateRiskAssessment({ + ...safeInput, + transaction: { + amount: "50000", + velocityCount: 10, + velocityWindowMinutes: 60, + unusualAsset: true, + unusualNetwork: true, + }, + }); + + expect(result.score).toBe(40); + expect(result.riskLevel).toBe("medium"); + }); + + it("caps risk score at 100", () => { + const result = calculateRiskAssessment({ + account: { + accountAgeDays: 1, + priorComplianceReviews: 10, + priorAlerts: 10, + }, + device: { + known: false, + recentDeviceChanges: 10, + failedAuthenticationAttempts: 10, + }, + withdrawal: { + amount: "100", + recentWithdrawalCount: 10, + newDestination: true, + recentlyChangedSecuritySettings: true, + }, + transaction: { + amount: "100", + velocityCount: 10, + velocityWindowMinutes: 10, + unusualAsset: true, + unusualNetwork: true, + }, + }); + + expect(result.score).toBe(100); + expect(result.riskLevel).toBe("critical"); + }); + + it("rejects invalid withdrawal amounts", () => { + expect(() => + calculateRiskAssessment({ + ...safeInput, + withdrawal: { + ...safeInput.withdrawal, + amount: "1.5", + }, + }), + ).toThrow("Withdrawal amount must be a non-negative integer string"); + }); + + it("rejects invalid transaction amounts", () => { + expect(() => + calculateRiskAssessment({ + ...safeInput, + transaction: { + ...safeInput.transaction, + amount: "-1", + }, + }), + ).toThrow("Transaction amount must be a non-negative integer string"); + }); +}); + +describe("validateTravelRuleTransfer", () => { + const validTransfer = { + transferId: "transfer-1", + assetId: "asset-1", + networkId: "network-1", + amount: "100000", + originator: { + type: "individual" as const, + name: "Alice", + accountIdentifier: "originator-account", + }, + beneficiary: { + type: "business" as const, + name: "Bob Corp", + accountIdentifier: "beneficiary-account", + }, + createdAt: "2026-09-15T10:00:00Z", + }; + + it("accepts a complete Travel Rule transfer", () => { + const result = validateTravelRuleTransfer(validTransfer); + + expect(result.valid).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("reports missing required Travel Rule data", () => { + const result = validateTravelRuleTransfer({ + ...validTransfer, + transferId: "", + assetId: "", + networkId: "", + amount: "0", + originator: { + ...validTransfer.originator, + name: "", + accountIdentifier: "", + }, + beneficiary: { + ...validTransfer.beneficiary, + name: "", + accountIdentifier: "", + }, + }); + + expect(result.valid).toBe(false); + expect(result.missing).toEqual([ + "missing_transfer_id", + "missing_asset_id", + "missing_network_id", + "invalid_amount", + "missing_originator_name", + "missing_originator_account", + "missing_beneficiary_name", + "missing_beneficiary_account", + ]); + }); +}); + +describe("ComplianceCaseManager", () => { + const signal = { + subjectId: "subject-1", + transactionType: "withdrawal" as const, + transactionId: "tx-1", + ruleCode: "HIGH_VELOCITY", + riskScore: 85, + reason: "High withdrawal velocity", + }; + + it("creates a critical case from suspicious activity", () => { + const manager = new ComplianceCaseManager(); + + const result = manager.createFromSuspiciousActivity(signal, "case-1", "2026-09-15T10:00:00Z"); + + expect(result.id).toBe("case-1"); + expect(result.subjectId).toBe("subject-1"); + expect(result.caseType).toBe("transaction_monitoring"); + expect(result.priority).toBe("critical"); + expect(result.status).toBe("open"); + }); + + it("maps suspicious activity score to high priority", () => { + const manager = new ComplianceCaseManager(); + + const result = manager.createFromSuspiciousActivity( + { + ...signal, + riskScore: 60, + }, + "case-2", + "2026-09-15T10:00:00Z", + ); + + expect(result.priority).toBe("high"); + }); + + it("maps lower suspicious activity score to normal priority", () => { + const manager = new ComplianceCaseManager(); + + const result = manager.createFromSuspiciousActivity( + { + ...signal, + riskScore: 30, + }, + "case-3", + "2026-09-15T10:00:00Z", + ); + + expect(result.priority).toBe("normal"); + }); + + it("enforces the compliance case lifecycle", () => { + const manager = new ComplianceCaseManager(); + + manager.createFromSuspiciousActivity(signal, "case-4", "2026-09-15T10:00:00Z"); + + const investigating = manager.startInvestigation( + "case-4", + "compliance-analyst", + "2026-09-15T10:05:00Z", + "Manual investigation started", + ); + + expect(investigating.status).toBe("investigating"); + + const escalated = manager.escalate( + "case-4", + "senior-analyst", + "2026-09-15T10:10:00Z", + "Escalating for enhanced review", + ); + + expect(escalated.status).toBe("escalated"); + + const resolved = manager.resolve( + "case-4", + "senior-analyst", + "2026-09-15T11:00:00Z", + "Investigation completed", + ); + + expect(resolved.status).toBe("resolved"); + expect(resolved.resolvedAt).toBe("2026-09-15T11:00:00Z"); + + const closed = manager.close( + "case-4", + "compliance-admin", + "2026-09-15T11:05:00Z", + "Case archived", + ); + + expect(closed.status).toBe("closed"); + + expect(manager.getTransitions("case-4")).toHaveLength(4); + }); + + it("rejects invalid case transitions", () => { + const manager = new ComplianceCaseManager(); + + manager.createFromSuspiciousActivity(signal, "case-5", "2026-09-15T10:00:00Z"); + + expect(() => + manager.resolve("case-5", "analyst", "2026-09-15T10:05:00Z", "Invalid direct resolution"), + ).toThrow("Only investigating or escalated cases can be resolved"); + }); + + it("rejects suspicious activity with an invalid risk score", () => { + const manager = new ComplianceCaseManager(); + + expect(() => + manager.createFromSuspiciousActivity( + { + ...signal, + riskScore: 101, + }, + "case-6", + "2026-09-15T10:00:00Z", + ), + ).toThrow("Risk score must be between 0 and 100"); + }); +}); diff --git a/packages/compliance-core/src/case-management.ts b/packages/compliance-core/src/case-management.ts new file mode 100644 index 0000000..cfda639 --- /dev/null +++ b/packages/compliance-core/src/case-management.ts @@ -0,0 +1,292 @@ +export type ComplianceCaseType = + | "kyc_review" + | "kyb_review" + | "sanctions_review" + | "risk_review" + | "transaction_monitoring" + | "manual_review"; + +export type ComplianceCasePriority = "low" | "normal" | "high" | "critical"; + +export type ComplianceCaseStatus = "open" | "investigating" | "escalated" | "resolved" | "closed"; + +export type ComplianceCaseAction = "start_investigation" | "escalate" | "resolve" | "close"; + +export interface ComplianceCase { + id: string; + subjectId?: string; + caseType: ComplianceCaseType; + priority: ComplianceCasePriority; + status: ComplianceCaseStatus; + reason: string; + createdAt: string; + updatedAt: string; + resolvedAt?: string; +} + +export interface SuspiciousActivitySignal { + subjectId?: string; + transactionType: "deposit" | "withdrawal" | "trade" | "transfer"; + transactionId: string; + ruleCode: string; + riskScore: number; + reason: string; +} + +export interface ComplianceCaseTransition { + caseId: string; + action: ComplianceCaseAction; + occurredAt: string; + actor: string; + reason: string; +} + +export class ComplianceCaseManager { + private readonly cases = new Map(); + + private readonly transitions = new Map(); + + create(caseRecord: ComplianceCase): ComplianceCase { + if (this.cases.has(caseRecord.id)) { + throw new Error(`Compliance case already exists: ${caseRecord.id}`); + } + + this.validateCase(caseRecord); + + const stored = Object.freeze({ + ...caseRecord, + }); + + this.cases.set(caseRecord.id, stored); + this.transitions.set(caseRecord.id, []); + + return stored; + } + + getById(id: string): ComplianceCase | null { + const caseRecord = this.cases.get(id); + + if (caseRecord === undefined) { + return null; + } + + return Object.freeze({ + ...caseRecord, + }); + } + + startInvestigation( + caseId: string, + actor: string, + occurredAt: string, + reason: string, + ): ComplianceCase { + return this.transition( + caseId, + { + action: "start_investigation", + actor, + occurredAt, + reason, + }, + (caseRecord) => { + if (caseRecord.status !== "open") { + throw new Error("Only open compliance cases can start investigation"); + } + + return { + ...caseRecord, + status: "investigating", + updatedAt: occurredAt, + }; + }, + ); + } + + escalate(caseId: string, actor: string, occurredAt: string, reason: string): ComplianceCase { + return this.transition( + caseId, + { + action: "escalate", + actor, + occurredAt, + reason, + }, + (caseRecord) => { + if (caseRecord.status !== "open" && caseRecord.status !== "investigating") { + throw new Error("Only open or investigating cases can be escalated"); + } + + return { + ...caseRecord, + status: "escalated", + updatedAt: occurredAt, + }; + }, + ); + } + + resolve(caseId: string, actor: string, occurredAt: string, reason: string): ComplianceCase { + return this.transition( + caseId, + { + action: "resolve", + actor, + occurredAt, + reason, + }, + (caseRecord) => { + if (caseRecord.status !== "investigating" && caseRecord.status !== "escalated") { + throw new Error("Only investigating or escalated cases can be resolved"); + } + + return { + ...caseRecord, + status: "resolved", + updatedAt: occurredAt, + resolvedAt: occurredAt, + }; + }, + ); + } + + close(caseId: string, actor: string, occurredAt: string, reason: string): ComplianceCase { + return this.transition( + caseId, + { + action: "close", + actor, + occurredAt, + reason, + }, + (caseRecord) => { + if (caseRecord.status !== "resolved") { + throw new Error("Only resolved compliance cases can be closed"); + } + + return { + ...caseRecord, + status: "closed", + updatedAt: occurredAt, + }; + }, + ); + } + + getTransitions(caseId: string): readonly ComplianceCaseTransition[] { + const transitions = this.transitions.get(caseId); + + if (transitions === undefined) { + return []; + } + + return Object.freeze(transitions.map((transition) => Object.freeze({ ...transition }))); + } + + createFromSuspiciousActivity( + signal: SuspiciousActivitySignal, + caseId: string, + createdAt: string, + ): ComplianceCase { + if (signal.transactionId.trim().length === 0) { + throw new Error("Transaction ID must not be empty"); + } + + if (signal.ruleCode.trim().length === 0) { + throw new Error("Rule code must not be empty"); + } + + if (signal.reason.trim().length === 0) { + throw new Error("Suspicious activity reason must not be empty"); + } + + if (signal.riskScore < 0 || signal.riskScore > 100) { + throw new Error("Risk score must be between 0 and 100"); + } + + const priority: ComplianceCasePriority = + signal.riskScore >= 80 ? "critical" : signal.riskScore >= 60 ? "high" : "normal"; + + return this.create({ + id: caseId, + ...(signal.subjectId !== undefined ? { subjectId: signal.subjectId } : {}), + caseType: "transaction_monitoring", + priority, + status: "open", + reason: signal.reason, + createdAt, + updatedAt: createdAt, + }); + } + + private transition( + caseId: string, + transition: Omit, + update: (caseRecord: ComplianceCase) => ComplianceCase, + ): ComplianceCase { + const existing = this.cases.get(caseId); + + if (existing === undefined) { + throw new Error(`Compliance case not found: ${caseId}`); + } + + this.validateTransition(transition); + + const updated = Object.freeze(update(existing)); + + this.cases.set(caseId, updated); + + const history = this.transitions.get(caseId); + + if (history === undefined) { + throw new Error(`Compliance case history not found: ${caseId}`); + } + + history.push( + Object.freeze({ + caseId, + ...transition, + }), + ); + + return updated; + } + + private validateCase(caseRecord: ComplianceCase): void { + if (caseRecord.id.trim().length === 0) { + throw new Error("Compliance case ID must not be empty"); + } + + if (caseRecord.reason.trim().length === 0) { + throw new Error("Compliance case reason must not be empty"); + } + + if (caseRecord.createdAt.trim().length === 0) { + throw new Error("Compliance case createdAt must not be empty"); + } + + if (caseRecord.updatedAt.trim().length === 0) { + throw new Error("Compliance case updatedAt must not be empty"); + } + + if (caseRecord.status === "resolved" || caseRecord.status === "closed") { + if (caseRecord.resolvedAt === undefined) { + throw new Error("Resolved and closed cases must have resolvedAt"); + } + } + } + + private validateTransition(transition: Omit): void { + if (transition.actor.trim().length === 0) { + throw new Error("Compliance case actor must not be empty"); + } + + if (transition.occurredAt.trim().length === 0) { + throw new Error("Compliance case transition time must not be empty"); + } + + if (transition.reason.trim().length === 0) { + throw new Error("Compliance case transition reason must not be empty"); + } + } +} diff --git a/packages/compliance-core/src/index.ts b/packages/compliance-core/src/index.ts new file mode 100644 index 0000000..2730a24 --- /dev/null +++ b/packages/compliance-core/src/index.ts @@ -0,0 +1,5 @@ +export * from "./types.js"; +export * from "./screening.js"; +export * from "./travel-rule.js"; +export * from "./risk-scoring.js"; +export * from "./case-management.js"; diff --git a/packages/compliance-core/src/risk-scoring.ts b/packages/compliance-core/src/risk-scoring.ts new file mode 100644 index 0000000..76e3887 --- /dev/null +++ b/packages/compliance-core/src/risk-scoring.ts @@ -0,0 +1,221 @@ +export type RiskFactorCategory = "account" | "device" | "withdrawal" | "transaction"; + +export interface RiskFactor { + category: RiskFactorCategory; + code: string; + score: number; + reason: string; +} + +export interface RiskScoringInput { + account: { + accountAgeDays: number; + priorComplianceReviews: number; + priorAlerts: number; + }; + + device: { + known: boolean; + recentDeviceChanges: number; + failedAuthenticationAttempts: number; + }; + + withdrawal: { + amount: string; + recentWithdrawalCount: number; + newDestination: boolean; + recentlyChangedSecuritySettings: boolean; + }; + + transaction: { + amount: string; + velocityCount: number; + velocityWindowMinutes: number; + unusualAsset: boolean; + unusualNetwork: boolean; + }; +} + +export type RiskLevel = "low" | "medium" | "high" | "critical"; + +export interface RiskAssessment { + score: number; + riskLevel: RiskLevel; + factors: readonly RiskFactor[]; +} + +function addFactor( + factors: RiskFactor[], + category: RiskFactorCategory, + code: string, + score: number, + reason: string, +): void { + if (score <= 0) { + return; + } + + factors.push({ + category, + code, + score, + reason, + }); +} + +function classifyRisk(score: number): RiskLevel { + if (score >= 80) { + return "critical"; + } + + if (score >= 60) { + return "high"; + } + + if (score >= 30) { + return "medium"; + } + + return "low"; +} + +function parsePositiveAmount(amount: string, field: string): bigint { + if (!/^[0-9]+$/.test(amount)) { + throw new Error(`${field} amount must be a non-negative integer string`); + } + + return BigInt(amount); +} + +export function calculateRiskAssessment(input: RiskScoringInput): RiskAssessment { + const factors: RiskFactor[] = []; + + if (input.account.accountAgeDays < 7) { + addFactor(factors, "account", "NEW_ACCOUNT", 15, "Account is less than seven days old"); + } + + if (input.account.priorComplianceReviews > 0) { + addFactor( + factors, + "account", + "PRIOR_COMPLIANCE_REVIEW", + Math.min(input.account.priorComplianceReviews * 5, 15), + "Account has prior compliance reviews", + ); + } + + if (input.account.priorAlerts > 0) { + addFactor( + factors, + "account", + "PRIOR_ALERTS", + Math.min(input.account.priorAlerts * 5, 20), + "Account has prior transaction-monitoring alerts", + ); + } + + if (!input.device.known) { + addFactor( + factors, + "device", + "UNKNOWN_DEVICE", + 15, + "Transaction originated from an unknown device", + ); + } + + if (input.device.recentDeviceChanges > 0) { + addFactor( + factors, + "device", + "RECENT_DEVICE_CHANGE", + Math.min(input.device.recentDeviceChanges * 10, 20), + "Recent device changes increase account-takeover risk", + ); + } + + if (input.device.failedAuthenticationAttempts > 0) { + addFactor( + factors, + "device", + "FAILED_AUTHENTICATION", + Math.min(input.device.failedAuthenticationAttempts * 3, 15), + "Recent authentication failures increase risk", + ); + } + + parsePositiveAmount(input.withdrawal.amount, "Withdrawal"); + + if (input.withdrawal.recentWithdrawalCount >= 5) { + addFactor( + factors, + "withdrawal", + "WITHDRAWAL_VELOCITY", + 15, + "Multiple recent withdrawals indicate elevated withdrawal velocity", + ); + } + + if (input.withdrawal.newDestination) { + addFactor( + factors, + "withdrawal", + "NEW_DESTINATION", + 20, + "Withdrawal destination has not previously been used", + ); + } + + if (input.withdrawal.recentlyChangedSecuritySettings) { + addFactor( + factors, + "withdrawal", + "RECENT_SECURITY_CHANGE", + 25, + "Security settings were recently changed before withdrawal", + ); + } + + parsePositiveAmount(input.transaction.amount, "Transaction"); + + if (input.transaction.velocityCount >= 10 && input.transaction.velocityWindowMinutes <= 60) { + addFactor( + factors, + "transaction", + "TRANSACTION_VELOCITY", + 20, + "Transaction velocity is unusually high", + ); + } + + if (input.transaction.unusualAsset) { + addFactor( + factors, + "transaction", + "UNUSUAL_ASSET", + 10, + "Transaction uses an unusual asset for the account", + ); + } + + if (input.transaction.unusualNetwork) { + addFactor( + factors, + "transaction", + "UNUSUAL_NETWORK", + 10, + "Transaction uses an unusual network for the account", + ); + } + + const score = Math.min( + factors.reduce((total, factor) => total + factor.score, 0), + 100, + ); + + return Object.freeze({ + score, + riskLevel: classifyRisk(score), + factors: Object.freeze(factors), + }); +} diff --git a/packages/compliance-core/src/screening.ts b/packages/compliance-core/src/screening.ts new file mode 100644 index 0000000..4b80523 --- /dev/null +++ b/packages/compliance-core/src/screening.ts @@ -0,0 +1,110 @@ +import type { + ScreeningObservation, + ScreeningRequest, + ScreeningStatus, + ScreeningType, + TransactionMonitoringContext, +} from "./types.js"; + +export interface ComplianceScreeningProvider { + readonly name: string; + + screen(request: ScreeningRequest): Promise; +} + +export interface TransactionMonitoringProvider { + readonly name: string; + + evaluate(context: TransactionMonitoringContext): Promise; +} + +export interface TransactionMonitoringDecision { + status: "clear" | "review" | "blocked"; + riskScore: number; + ruleCodes: readonly string[]; + reason: string; +} + +export class ScreeningService { + private readonly providers = new Map(); + + register(screeningType: ScreeningType, provider: ComplianceScreeningProvider): void { + if (this.providers.has(screeningType)) { + throw new Error(`Screening provider already registered for ${screeningType}`); + } + + this.providers.set(screeningType, provider); + } + + async screen(request: ScreeningRequest): Promise { + const provider = this.providers.get(request.screeningType); + + if (provider === undefined) { + throw new Error(`No screening provider registered for ${request.screeningType}`); + } + + const observation = await provider.screen(request); + + this.validateObservation(observation); + + return Object.freeze({ ...observation }); + } + + private validateObservation(observation: ScreeningObservation): void { + if (observation.provider.trim().length === 0) { + throw new Error("Screening provider must not be empty"); + } + + if (observation.providerReference !== undefined) { + if (observation.providerReference.trim().length === 0) { + throw new Error("Screening provider reference must not be empty"); + } + } + + if (observation.screenedAt.trim().length === 0) { + throw new Error("Screening timestamp must not be empty"); + } + + const validStatuses: readonly ScreeningStatus[] = [ + "pending", + "clear", + "match", + "review", + "failed", + ]; + + if (!validStatuses.includes(observation.status)) { + throw new Error(`Invalid screening status: ${observation.status}`); + } + } +} + +export class TransactionMonitoringService { + private readonly providers: TransactionMonitoringProvider[] = []; + + register(provider: TransactionMonitoringProvider): void { + this.providers.push(provider); + } + + async evaluate(context: TransactionMonitoringContext): Promise { + if (this.providers.length === 0) { + throw new Error("No transaction monitoring provider registered"); + } + + const decisions = await Promise.all( + this.providers.map((provider) => provider.evaluate(context)), + ); + + for (const decision of decisions) { + if (decision.riskScore < 0 || decision.riskScore > 100) { + throw new Error("Transaction monitoring risk score must be 0-100"); + } + + if (decision.reason.trim().length === 0) { + throw new Error("Transaction monitoring reason must not be empty"); + } + } + + return decisions.map((decision) => Object.freeze({ ...decision })); + } +} diff --git a/packages/compliance-core/src/travel-rule.ts b/packages/compliance-core/src/travel-rule.ts new file mode 100644 index 0000000..3dc4623 --- /dev/null +++ b/packages/compliance-core/src/travel-rule.ts @@ -0,0 +1,81 @@ +export type TravelRulePartyType = "individual" | "business"; + +export interface TravelRuleParty { + type: TravelRulePartyType; + name: string; + accountIdentifier: string; + institutionName?: string; + institutionIdentifier?: string; + jurisdiction?: string; +} + +export interface TravelRuleTransfer { + transferId: string; + assetId: string; + networkId: string; + amount: string; + originator: TravelRuleParty; + beneficiary: TravelRuleParty; + originatorVasp?: string; + beneficiaryVasp?: string; + transactionReference?: string; + createdAt: string; +} + +export type TravelRuleValidationCode = + | "missing_transfer_id" + | "missing_asset_id" + | "missing_network_id" + | "invalid_amount" + | "missing_originator_name" + | "missing_originator_account" + | "missing_beneficiary_name" + | "missing_beneficiary_account"; + +export interface TravelRuleValidationResult { + valid: boolean; + missing: readonly TravelRuleValidationCode[]; +} + +export function validateTravelRuleTransfer( + transfer: TravelRuleTransfer, +): TravelRuleValidationResult { + const missing: TravelRuleValidationCode[] = []; + + if (transfer.transferId.trim().length === 0) { + missing.push("missing_transfer_id"); + } + + if (transfer.assetId.trim().length === 0) { + missing.push("missing_asset_id"); + } + + if (transfer.networkId.trim().length === 0) { + missing.push("missing_network_id"); + } + + if (!/^[0-9]+$/.test(transfer.amount) || BigInt(transfer.amount) <= 0n) { + missing.push("invalid_amount"); + } + + if (transfer.originator.name.trim().length === 0) { + missing.push("missing_originator_name"); + } + + if (transfer.originator.accountIdentifier.trim().length === 0) { + missing.push("missing_originator_account"); + } + + if (transfer.beneficiary.name.trim().length === 0) { + missing.push("missing_beneficiary_name"); + } + + if (transfer.beneficiary.accountIdentifier.trim().length === 0) { + missing.push("missing_beneficiary_account"); + } + + return Object.freeze({ + valid: missing.length === 0, + missing: Object.freeze(missing), + }); +} diff --git a/packages/compliance-core/src/types.ts b/packages/compliance-core/src/types.ts new file mode 100644 index 0000000..2974146 --- /dev/null +++ b/packages/compliance-core/src/types.ts @@ -0,0 +1,42 @@ +export type ComplianceSubjectType = "individual" | "business"; + +export type ComplianceSubjectStatus = "active" | "restricted" | "blocked" | "closed"; + +export type ComplianceRiskLevel = "unknown" | "low" | "medium" | "high" | "critical"; + +export type ScreeningType = "kyc" | "kyb" | "sanctions" | "pep" | "adverse_media"; + +export type ScreeningStatus = "pending" | "clear" | "match" | "review" | "failed"; + +export interface ComplianceSubject { + id: string; + userId: string; + subjectType: ComplianceSubjectType; + status: ComplianceSubjectStatus; + riskLevel: ComplianceRiskLevel; +} + +export interface ScreeningRequest { + subjectId: string; + screeningType: ScreeningType; + reference: string; +} + +export interface ScreeningObservation { + screeningType: ScreeningType; + status: ScreeningStatus; + provider: string; + providerReference?: string; + screenedAt: string; +} + +export type TransactionMonitoringType = "deposit" | "withdrawal" | "trade" | "transfer"; + +export interface TransactionMonitoringContext { + subjectId?: string; + transactionType: TransactionMonitoringType; + transactionId: string; + amount: string; + assetId: string; + occurredAt: string; +} diff --git a/packages/compliance-core/tsconfig.json b/packages/compliance-core/tsconfig.json new file mode 100644 index 0000000..6d34b17 --- /dev/null +++ b/packages/compliance-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmitOnError": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/storage/migrations/016_compliance_risk.sql b/packages/storage/migrations/016_compliance_risk.sql new file mode 100644 index 0000000..f8cf586 --- /dev/null +++ b/packages/storage/migrations/016_compliance_risk.sql @@ -0,0 +1,319 @@ +-- Compliance and risk domain persistence foundation +-- +-- Compliance is a separate domain from identity, exchange, ledger, +-- and custody. These tables store compliance decisions, screening +-- observations, risk assessments, alerts, and cases. +-- +-- Do not store passwords, private keys, seed phrases, signing secrets, +-- authentication secrets, or raw identity documents here. + +CREATE TABLE IF NOT EXISTS compliance_subjects ( + id UUID PRIMARY KEY, + + user_id UUID NOT NULL, + + subject_type TEXT NOT NULL, + + status TEXT NOT NULL DEFAULT 'active', + + risk_level TEXT NOT NULL DEFAULT 'unknown', + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + + CONSTRAINT compliance_subjects_subject_type_check + CHECK ( + subject_type IN ( + 'individual', + 'business' + ) + ), + + CONSTRAINT compliance_subjects_status_check + CHECK ( + status IN ( + 'active', + 'restricted', + 'blocked', + 'closed' + ) + ), + + CONSTRAINT compliance_subjects_risk_level_check + CHECK ( + risk_level IN ( + 'unknown', + 'low', + 'medium', + 'high', + 'critical' + ) + ), + + UNIQUE(user_id) +); + +CREATE INDEX IF NOT EXISTS idx_compliance_subjects_status + ON compliance_subjects(status); + +CREATE INDEX IF NOT EXISTS idx_compliance_subjects_risk_level + ON compliance_subjects(risk_level); + + +CREATE TABLE IF NOT EXISTS compliance_screenings ( + id UUID PRIMARY KEY, + + subject_id UUID NOT NULL + REFERENCES compliance_subjects(id), + + screening_type TEXT NOT NULL, + + provider TEXT NOT NULL, + + status TEXT NOT NULL, + + provider_reference TEXT, + + result JSONB NOT NULL DEFAULT '{}'::jsonb, + + screened_at TIMESTAMPTZ NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT compliance_screenings_type_check + CHECK ( + screening_type IN ( + 'kyc', + 'kyb', + 'sanctions', + 'pep', + 'adverse_media' + ) + ), + + CONSTRAINT compliance_screenings_provider_not_empty + CHECK (length(trim(provider)) > 0), + + CONSTRAINT compliance_screenings_status_check + CHECK ( + status IN ( + 'pending', + 'clear', + 'match', + 'review', + 'failed' + ) + ), + + CONSTRAINT compliance_screenings_reference_not_empty + CHECK ( + provider_reference IS NULL + OR length(trim(provider_reference)) > 0 + ) +); + +CREATE INDEX IF NOT EXISTS idx_compliance_screenings_subject + ON compliance_screenings(subject_id, screened_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compliance_screenings_type + ON compliance_screenings(screening_type, screened_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compliance_screenings_status + ON compliance_screenings(status); + + +CREATE TABLE IF NOT EXISTS compliance_risk_assessments ( + id UUID PRIMARY KEY, + + subject_id UUID NOT NULL + REFERENCES compliance_subjects(id), + + score INTEGER NOT NULL, + + risk_level TEXT NOT NULL, + + reasons JSONB NOT NULL DEFAULT '[]'::jsonb, + + assessed_at TIMESTAMPTZ NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT compliance_risk_assessments_score_check + CHECK (score >= 0 AND score <= 100), + + CONSTRAINT compliance_risk_assessments_risk_level_check + CHECK ( + risk_level IN ( + 'low', + 'medium', + 'high', + 'critical' + ) + ) +); + +CREATE INDEX IF NOT EXISTS idx_compliance_risk_assessments_subject + ON compliance_risk_assessments(subject_id, assessed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compliance_risk_assessments_level + ON compliance_risk_assessments(risk_level, assessed_at DESC); + + +CREATE TABLE IF NOT EXISTS transaction_monitoring_alerts ( + id UUID PRIMARY KEY, + + subject_id UUID + REFERENCES compliance_subjects(id), + + transaction_type TEXT NOT NULL, + + transaction_id TEXT NOT NULL, + + rule_code TEXT NOT NULL, + + risk_score INTEGER NOT NULL, + + status TEXT NOT NULL DEFAULT 'open', + + details JSONB NOT NULL DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + resolved_at TIMESTAMPTZ, + + CONSTRAINT transaction_monitoring_alerts_transaction_type_check + CHECK ( + transaction_type IN ( + 'deposit', + 'withdrawal', + 'trade', + 'transfer' + ) + ), + + CONSTRAINT transaction_monitoring_alerts_transaction_id_not_empty + CHECK (length(trim(transaction_id)) > 0), + + CONSTRAINT transaction_monitoring_alerts_rule_code_not_empty + CHECK (length(trim(rule_code)) > 0), + + CONSTRAINT transaction_monitoring_alerts_score_check + CHECK (risk_score >= 0 AND risk_score <= 100), + + CONSTRAINT transaction_monitoring_alerts_status_check + CHECK ( + status IN ( + 'open', + 'under_review', + 'dismissed', + 'escalated', + 'resolved' + ) + ), + + CONSTRAINT transaction_monitoring_alerts_resolution_check + CHECK ( + ( + status IN ('open', 'under_review', 'escalated') + AND resolved_at IS NULL + ) + OR + ( + status IN ('dismissed', 'resolved') + AND resolved_at IS NOT NULL + ) + ) +); + +CREATE INDEX IF NOT EXISTS idx_transaction_monitoring_alerts_subject + ON transaction_monitoring_alerts(subject_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_transaction_monitoring_alerts_status + ON transaction_monitoring_alerts(status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_transaction_monitoring_alerts_transaction + ON transaction_monitoring_alerts(transaction_type, transaction_id); + + +CREATE TABLE IF NOT EXISTS compliance_cases ( + id UUID PRIMARY KEY, + + subject_id UUID + REFERENCES compliance_subjects(id), + + case_type TEXT NOT NULL, + + priority TEXT NOT NULL DEFAULT 'normal', + + status TEXT NOT NULL DEFAULT 'open', + + reason TEXT NOT NULL, + + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + resolved_at TIMESTAMPTZ, + + CONSTRAINT compliance_cases_type_check + CHECK ( + case_type IN ( + 'kyc_review', + 'kyb_review', + 'sanctions_review', + 'risk_review', + 'transaction_monitoring', + 'manual_review' + ) + ), + + CONSTRAINT compliance_cases_priority_check + CHECK ( + priority IN ( + 'low', + 'normal', + 'high', + 'critical' + ) + ), + + CONSTRAINT compliance_cases_status_check + CHECK ( + status IN ( + 'open', + 'investigating', + 'escalated', + 'resolved', + 'closed' + ) + ), + + CONSTRAINT compliance_cases_reason_not_empty + CHECK (length(trim(reason)) > 0), + + CONSTRAINT compliance_cases_resolution_check + CHECK ( + ( + status IN ('open', 'investigating', 'escalated') + AND resolved_at IS NULL + ) + OR + ( + status IN ('resolved', 'closed') + AND resolved_at IS NOT NULL + ) + ) +); + +CREATE INDEX IF NOT EXISTS idx_compliance_cases_subject + ON compliance_cases(subject_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compliance_cases_status + ON compliance_cases(status, priority, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compliance_cases_type + ON compliance_cases(case_type, created_at DESC); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7494e10..d1dc228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,6 +494,12 @@ importers: specifier: 2.3.0 version: 2.3.0 + packages/compliance-core: + devDependencies: + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + packages/config: devDependencies: "@types/node": @@ -521,6 +527,7 @@ importers: vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.12)(yaml@2.9.0)) + packages/dapp-core: dependencies: "@crypto-wallet/shared-types":