diff --git a/backend/src/index.ts b/backend/src/index.ts index 36757a75..0b069062 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -57,6 +57,10 @@ import { notificationsRouter } from './routes/notifications.js'; import { auditRouter } from './routes/audit.js'; import { hedgingRouter } from './routes/hedging.js'; import { complianceRouter } from './routes/compliance.js'; +import { amlMonitoringRouter } from './routes/aml-monitoring.js'; +import { sanctionsScreeningRouter } from './routes/sanctions.js'; +import { pciDssRouter } from './routes/pci-dss.js'; +import { nanoRouter } from './routes/nano.js'; import { gdprRouter } from './routes/gdpr.js'; import dataExportRouter from './routes/dataExport.js'; import securityRouter from './routes/security.js'; @@ -378,6 +382,10 @@ app.use('/api/v1/notifications', notificationsRouter); app.use('/api/v1/audit', auditRouter); app.use('/api/v1/hedging', hedgingRouter); app.use('/api/v1/compliance', complianceRouter); +app.use('/api/v1/aml', amlMonitoringRouter); +app.use('/api/v1/sanctions', sanctionsScreeningRouter); +app.use('/api/v1/pci-dss', pciDssRouter); +app.use('/api/v1/nano', nanoRouter); app.use('/api/v1/gdpr', gdprRouter); app.use('/api/v1/escrow', escrowRouter); app.use('/api/v1/multisig', multisigRouter); diff --git a/backend/src/routes/aml-monitoring.ts b/backend/src/routes/aml-monitoring.ts new file mode 100644 index 00000000..1eb40fdd --- /dev/null +++ b/backend/src/routes/aml-monitoring.ts @@ -0,0 +1,151 @@ +/** + * AML transaction monitoring API — Issue #922 + * + * Endpoints: + * - GET /aml/rules → rule catalogue and thresholds + * - POST /aml/score → score a single transaction + * - POST /aml/monitor → batch score + auto-create alerts + * - GET /aml/alerts → list alerts (filter by status/severity/account) + * - PATCH /aml/alerts/:id → triage an alert + */ + +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + AML_THRESHOLDS, + AmlAccountContext, + AmlAlertSeverity, + AmlAlertStatus, + AmlTransaction, + getAmlAlert, + getAmlAlerts, + getAmlRuleCatalogue, + monitorAmlTransactions, + resolveAmlAlert, + scoreAmlRisk, +} from '../services/aml/transaction-monitor.js'; + +export const amlMonitoringRouter = Router(); + +const transactionSchema = z.object({ + transactionId: z.string().min(1), + accountId: z.string().min(1), + counterpartyId: z.string().min(1).optional(), + amount: z.number(), + currency: z.string().min(1), + timestamp: z.union([z.number(), z.string().min(1)]), + direction: z.enum(['inbound', 'outbound']).optional(), + channel: z.enum(['crypto', 'bank', 'card', 'wallet']).optional(), + originCountry: z.string().optional(), + destinationCountry: z.string().optional(), + counterpartySanctioned: z.boolean().optional(), + counterpartyIsPep: z.boolean().optional(), + structuringSignals: z.number().int().nonnegative().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +const accountContextSchema = z.object({ + accountId: z.string().min(1), + accountAgeDays: z.number().nonnegative().optional(), + kycVerified: z.boolean().optional(), + customerRiskRating: z.enum(['low', 'medium', 'high']).optional(), + transactionsLastHour: z.number().int().nonnegative().optional(), + transactionsLast24h: z.number().int().nonnegative().optional(), + totalAmountLast24h: z.number().nonnegative().optional(), + baselineAmount: z.number().nonnegative().optional(), + distinctCounterpartiesLast24h: z.number().int().nonnegative().optional(), + highRiskCountries: z.array(z.string()).optional(), + knownCounterparties: z.array(z.string()).optional(), +}); + +const monitorSchema = z.object({ + transactions: z.array(transactionSchema).min(1), + accounts: z.array(accountContextSchema).optional(), +}); + +const triageSchema = z.object({ + status: z.enum(['under_review', 'resolved', 'dismissed']), + reviewedBy: z.string().min(1).optional(), + resolution: z.string().min(1).optional(), +}); + +amlMonitoringRouter.get( + '/rules', + asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ success: true, data: { rules: getAmlRuleCatalogue(), thresholds: AML_THRESHOLDS } }); + }), +); + +amlMonitoringRouter.post( + '/score', + validate(z.object({ transaction: transactionSchema, account: accountContextSchema.optional() })), + asyncHandler(async (req: Request, res: Response) => { + const { transaction, account } = req.body as { + transaction: AmlTransaction; + account?: AmlAccountContext; + }; + const assessment = scoreAmlRisk(transaction, account); + res.status(200).json({ success: true, data: assessment }); + }), +); + +amlMonitoringRouter.post( + '/monitor', + validate(monitorSchema), + asyncHandler(async (req: Request, res: Response) => { + const { transactions, accounts } = req.body as { + transactions: AmlTransaction[]; + accounts?: AmlAccountContext[]; + }; + + const accountContexts: Record = {}; + for (const account of accounts ?? []) { + accountContexts[account.accountId] = account; + } + + const result = monitorAmlTransactions(transactions, accountContexts); + res.status(200).json({ success: true, data: result }); + }), +); + +amlMonitoringRouter.get( + '/alerts', + asyncHandler(async (req: Request, res: Response) => { + const status = req.query.status as AmlAlertStatus | undefined; + const severity = req.query.severity as AmlAlertSeverity | undefined; + const accountId = req.query.accountId as string | undefined; + const list = getAmlAlerts({ status, severity, accountId }); + res.status(200).json({ success: true, data: list }); + }), +); + +amlMonitoringRouter.get( + '/alerts/:id', + asyncHandler(async (req: Request, res: Response) => { + const alert = getAmlAlert(req.params.id); + if (!alert) { + res.status(404).json({ success: false, error: { message: 'AML alert not found' } }); + return; + } + res.status(200).json({ success: true, data: alert }); + }), +); + +amlMonitoringRouter.patch( + '/alerts/:id', + validate(triageSchema), + asyncHandler(async (req: Request, res: Response) => { + try { + const { status, reviewedBy, resolution } = req.body as z.infer; + const alert = resolveAmlAlert(req.params.id, status, { reviewedBy, resolution }); + res.status(200).json({ success: true, data: alert }); + } catch (error) { + res.status(404).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Unable to update AML alert' }, + }); + } + }), +); diff --git a/backend/src/routes/nano.ts b/backend/src/routes/nano.ts new file mode 100644 index 00000000..86257ea5 --- /dev/null +++ b/backend/src/routes/nano.ts @@ -0,0 +1,183 @@ +/** + * Nano (XNO) settle rail API — Issue #1004 + * + * Endpoints: + * - GET /nano/status → rail info (feeless, finality, metrics) + * - POST /nano/escrows → hold funds (one feeless send) + * - POST /nano/escrows/:id/release → release funds (one feeless send = confirmation) + * - POST /nano/escrows/:id/refund → refund funds (one feeless send) + * - GET /nano/escrows → list escrows (?status=&payer=&payee=) + * - GET /nano/escrows/:id → fetch an escrow + * - GET /nano/escrows/:id/receipt → verify the public release receipt + * - GET /nano/blocks/:hash → look up a block by hash + * - POST /nano/fund → fund an account (sandbox/faucet helper) + */ + +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + NANO_CONFIG, + NanoEscrowStatus, + SUPPORTED_SETTLEMENT_RAILS, + createNanoSettleRail, +} from '../services/settlement/nano-settle-rail.js'; + +export const nanoRouter = Router(); +export const nanoRail = createNanoSettleRail(); + +const holdSchema = z.object({ + payer: z.string().min(1), + payee: z.string().min(1), + amountXno: z.number().positive(), + representative: z.string().min(1).optional(), + escrowId: z.string().min(1).optional(), + idempotencyKey: z.string().min(1).optional(), + metadata: z.record(z.unknown()).optional(), +}); + +const releaseSchema = z.object({ + approvedBy: z.string().min(1).optional(), +}); + +const refundSchema = z.object({ + reason: z.string().min(1).optional(), +}); + +const fundSchema = z.object({ + address: z.string().min(1), + amountXno: z.number().positive(), +}); + +nanoRouter.get( + '/status', + asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ + success: true, + data: { + rail: 'nano', + ticker: NANO_CONFIG.ticker, + decimals: NANO_CONFIG.decimals, + feeless: true, + finalityMs: NANO_CONFIG.finalityMs, + supportedRails: SUPPORTED_SETTLEMENT_RAILS, + metrics: nanoRail.getMetrics(), + }, + }); + }), +); + +nanoRouter.post( + '/escrows', + validate(holdSchema), + asyncHandler(async (req: Request, res: Response) => { + try { + const escrow = nanoRail.holdEscrow(req.body); + res.status(201).json({ success: true, data: escrow }); + } catch (error) { + res.status(400).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Unable to hold escrow' }, + }); + } + }), +); + +nanoRouter.get( + '/escrows', + asyncHandler(async (req: Request, res: Response) => { + const status = req.query.status as NanoEscrowStatus | undefined; + const payer = req.query.payer as string | undefined; + const payee = req.query.payee as string | undefined; + res.status(200).json({ success: true, data: nanoRail.listEscrows({ status, payer, payee }) }); + }), +); + +nanoRouter.get( + '/escrows/:id', + asyncHandler(async (req: Request, res: Response) => { + const escrow = nanoRail.getEscrow(req.params.id); + if (!escrow) { + res.status(404).json({ success: false, error: { message: 'Escrow not found' } }); + return; + } + res.status(200).json({ success: true, data: escrow }); + }), +); + +nanoRouter.post( + '/escrows/:id/release', + validate(releaseSchema), + asyncHandler(async (req: Request, res: Response) => { + try { + const escrow = nanoRail.releaseEscrow(req.params.id, req.body); + res.status(200).json({ success: true, data: escrow }); + } catch (error) { + res.status(400).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Unable to release escrow' }, + }); + } + }), +); + +nanoRouter.post( + '/escrows/:id/refund', + validate(refundSchema), + asyncHandler(async (req: Request, res: Response) => { + try { + const escrow = nanoRail.refundEscrow(req.params.id, req.body); + res.status(200).json({ success: true, data: escrow }); + } catch (error) { + res.status(400).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Unable to refund escrow' }, + }); + } + }), +); + +nanoRouter.get( + '/escrows/:id/receipt', + asyncHandler(async (req: Request, res: Response) => { + try { + const receipt = nanoRail.verifyEscrowReceipt(req.params.id); + res.status(200).json({ success: true, data: receipt }); + } catch (error) { + res.status(404).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Escrow not found' }, + }); + } + }), +); + +nanoRouter.get( + '/blocks/:hash', + asyncHandler(async (req: Request, res: Response) => { + const block = nanoRail.getBlock(req.params.hash); + if (!block) { + res.status(404).json({ success: false, error: { message: 'Block not found' } }); + return; + } + res.status(200).json({ success: true, data: block }); + }), +); + +nanoRouter.post( + '/fund', + validate(fundSchema), + asyncHandler(async (req: Request, res: Response) => { + const { address, amountXno } = req.body as z.infer; + try { + nanoRail.fundAccount(address, amountXno); + res.status(200).json({ success: true, data: { address, balanceXno: nanoRail.getBalanceXno(address) } }); + } catch (error) { + res.status(400).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Unable to fund account' }, + }); + } + }), +); diff --git a/backend/src/routes/pci-dss.ts b/backend/src/routes/pci-dss.ts new file mode 100644 index 00000000..41da24ff --- /dev/null +++ b/backend/src/routes/pci-dss.ts @@ -0,0 +1,102 @@ +/** + * PCI DSS compliance dashboard API — Issue #924 + * + * Endpoints: + * - GET /pci-dss/dashboard → full dashboard (score, requirements, gaps) + * - GET /pci-dss/requirements → per-requirement assessments + * - GET /pci-dss/gaps → prioritised gaps (filter by ?priority=) + * - GET /pci-dss/controls/:id → a single control assessment + * - PATCH /pci-dss/controls/:id → record a control's status/evidence + * - POST /pci-dss/reset → restore the default sample posture + * - GET /pci-dss/export/evidence.csv → control evidence export + */ + +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + PciDssPriority, + assessPciDss, + buildPciDssDashboard, + exportPciDssEvidenceCsv, + getControlStatus, + getPciDssGaps, + resetPciDssEvidence, + setControlStatus, +} from '../services/pci-dss.js'; + +export const pciDssRouter = Router(); + +const controlUpdateSchema = z.object({ + status: z.enum(['in_place', 'not_in_place', 'not_tested', 'not_applicable']), + evidence: z.string().optional(), +}); + +pciDssRouter.get( + '/dashboard', + asyncHandler(async (req: Request, res: Response) => { + const assessedBy = req.query.assessedBy as string | undefined; + res.status(200).json({ success: true, data: buildPciDssDashboard({ assessedBy }) }); + }), +); + +pciDssRouter.get( + '/requirements', + asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ success: true, data: assessPciDss() }); + }), +); + +pciDssRouter.get( + '/gaps', + asyncHandler(async (req: Request, res: Response) => { + const priority = req.query.priority as PciDssPriority | undefined; + res.status(200).json({ success: true, data: getPciDssGaps(priority) }); + }), +); + +pciDssRouter.get( + '/controls/:id', + asyncHandler(async (req: Request, res: Response) => { + const control = getControlStatus(req.params.id); + if (!control) { + res.status(404).json({ success: false, error: { message: 'Unknown PCI DSS control' } }); + return; + } + res.status(200).json({ success: true, data: control }); + }), +); + +pciDssRouter.patch( + '/controls/:id', + validate(controlUpdateSchema), + asyncHandler(async (req: Request, res: Response) => { + const { status, evidence } = req.body as z.infer; + try { + const updated = setControlStatus(req.params.id, status, evidence); + res.status(200).json({ success: true, data: updated }); + } catch (error) { + res.status(404).json({ + success: false, + error: { message: error instanceof Error ? error.message : 'Unable to update control' }, + }); + } + }), +); + +pciDssRouter.post( + '/reset', + asyncHandler(async (_req: Request, res: Response) => { + resetPciDssEvidence(); + res.status(200).json({ success: true, data: buildPciDssDashboard() }); + }), +); + +pciDssRouter.get( + '/export/evidence.csv', + asyncHandler(async (_req: Request, res: Response) => { + res.setHeader('Content-Type', 'text/csv'); + res.status(200).send(exportPciDssEvidenceCsv()); + }), +); diff --git a/backend/src/routes/sanctions.ts b/backend/src/routes/sanctions.ts new file mode 100644 index 00000000..df850f38 --- /dev/null +++ b/backend/src/routes/sanctions.ts @@ -0,0 +1,182 @@ +/** + * Sanctions screening API — Issue #923 + * + * Endpoints: + * - GET /sanctions/lists → loaded lists and watchlist size + * - GET /sanctions/entities → list watchlist entries + * - POST /sanctions/entities → add/replace a watchlist entry + * - DELETE /sanctions/entities/:id → remove a watchlist entry + * - POST /sanctions/screen → screen a name/counterparty + * - POST /sanctions/screen/transaction → screen payer and payee together + * - GET /sanctions/history → recent screening decisions + * - POST /sanctions/reload → bulk-load list data (scheduled refresh) + */ + +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + SanctionsEntityType, + SanctionsListName, + getScreeningHistory, + getSanctionsEntity, + getSanctionsListSize, + listSanctionsEntities, + loadSanctionsEntries, + removeSanctionsEntity, + screenEntity, + screenTransaction, + upsertSanctionsEntity, +} from '../services/sanctions/ofac-screening.js'; + +export const sanctionsScreeningRouter = Router(); + +const listNameSchema = z.enum(['OFAC_SDN', 'OFAC_CONSOLIDATED', 'EU', 'UN', 'UK_OFSI']); +const entityTypeSchema = z.enum(['individual', 'entity', 'vessel', 'aircraft']); + +const querySchema = z.object({ + name: z.string().min(1), + aliases: z.array(z.string()).optional(), + entityType: entityTypeSchema.optional(), + countries: z.array(z.string()).optional(), + dateOfBirth: z.string().optional(), + nationalId: z.string().optional(), +}); + +const screeningOptionsSchema = z.object({ + threshold: z.number().min(0).max(1).optional(), + potentialThreshold: z.number().min(0).max(1).optional(), + lists: z.array(listNameSchema).optional(), + maxResults: z.number().int().positive().max(100).optional(), +}); + +const entitySchema = z.object({ + id: z.string().min(1).optional(), + list: listNameSchema, + name: z.string().min(1), + aliases: z.array(z.string()).optional(), + entityType: entityTypeSchema, + programs: z.array(z.string()).optional(), + countries: z.array(z.string()).optional(), + dateOfBirth: z.string().optional(), + nationalIds: z.array(z.string()).optional(), + remarks: z.string().optional(), +}); + +const screenRequestSchema = z.object({ + query: querySchema, + options: screeningOptionsSchema.optional(), +}); + +const transactionScreenSchema = z.object({ + transactionId: z.string().optional(), + payer: querySchema.optional(), + payee: querySchema.optional(), + options: screeningOptionsSchema.optional(), +}); + +const reloadSchema = z.object({ + entries: z.array(entitySchema.required({ id: true })), + replace: z.boolean().optional(), +}); + +sanctionsScreeningRouter.get( + '/lists', + asyncHandler(async (_req: Request, res: Response) => { + res.status(200).json({ + success: true, + data: { + supportedLists: ['OFAC_SDN', 'OFAC_CONSOLIDATED', 'EU', 'UN', 'UK_OFSI'] satisfies SanctionsListName[], + watchlistSize: getSanctionsListSize(), + }, + }); + }), +); + +sanctionsScreeningRouter.get( + '/entities', + asyncHandler(async (req: Request, res: Response) => { + const list = req.query.list as SanctionsListName | undefined; + const entityType = req.query.entityType as SanctionsEntityType | undefined; + const country = req.query.country as string | undefined; + const entities = listSanctionsEntities({ list, entityType, country }); + res.status(200).json({ success: true, data: entities }); + }), +); + +sanctionsScreeningRouter.get( + '/entities/:id', + asyncHandler(async (req: Request, res: Response) => { + const entity = getSanctionsEntity(req.params.id); + if (!entity) { + res.status(404).json({ success: false, error: { message: 'Sanctions entity not found' } }); + return; + } + res.status(200).json({ success: true, data: entity }); + }), +); + +sanctionsScreeningRouter.post( + '/entities', + validate(entitySchema), + asyncHandler(async (req: Request, res: Response) => { + const entity = upsertSanctionsEntity(req.body); + res.status(201).json({ success: true, data: entity }); + }), +); + +sanctionsScreeningRouter.delete( + '/entities/:id', + asyncHandler(async (req: Request, res: Response) => { + const removed = removeSanctionsEntity(req.params.id); + if (!removed) { + res.status(404).json({ success: false, error: { message: 'Sanctions entity not found' } }); + return; + } + res.status(200).json({ success: true, data: { removed: true } }); + }), +); + +sanctionsScreeningRouter.post( + '/screen', + validate(screenRequestSchema), + asyncHandler(async (req: Request, res: Response) => { + const { query, options } = req.body as z.infer; + const result = screenEntity(query, options); + res.status(200).json({ success: true, data: result }); + }), +); + +sanctionsScreeningRouter.post( + '/screen/transaction', + validate(transactionScreenSchema), + asyncHandler(async (req: Request, res: Response) => { + const { transactionId, payer, payee, options } = req.body as z.infer; + if (!payer && !payee) { + res.status(400).json({ success: false, error: { message: 'At least one of payer or payee is required' } }); + return; + } + const result = screenTransaction({ transactionId, payer, payee }, options); + res.status(200).json({ success: true, data: result }); + }), +); + +sanctionsScreeningRouter.get( + '/history', + asyncHandler(async (req: Request, res: Response) => { + const status = req.query.status as 'clear' | 'potential_match' | 'match' | undefined; + const since = req.query.since as string | undefined; + res.status(200).json({ success: true, data: getScreeningHistory({ status, since }) }); + }), +); + +sanctionsScreeningRouter.post( + '/reload', + validate(reloadSchema), + asyncHandler(async (req: Request, res: Response) => { + const { entries, replace } = req.body as z.infer; + const loaded = loadSanctionsEntries(entries, { replace }); + res.status(200).json({ success: true, data: { loaded, watchlistSize: getSanctionsListSize() } }); + }), +); diff --git a/backend/src/services/__tests__/pci-dss.test.ts b/backend/src/services/__tests__/pci-dss.test.ts new file mode 100644 index 00000000..9b44d4b3 --- /dev/null +++ b/backend/src/services/__tests__/pci-dss.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + PCI_DSS_REQUIREMENTS, + assessPciDss, + buildPciDssDashboard, + exportPciDssEvidenceCsv, + getControlStatus, + getPciDssGaps, + resetPciDssEvidence, + setControlStatus, +} from '../../pci-dss.js'; + +beforeEach(() => resetPciDssEvidence()); + +describe('PCI DSS catalogue (#924)', () => { + it('defines the twelve PCI DSS v4.0 requirements', () => { + expect(PCI_DSS_REQUIREMENTS).toHaveLength(12); + expect(PCI_DSS_REQUIREMENTS.map((r) => r.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + expect(PCI_DSS_REQUIREMENTS.every((r) => r.controls.length > 0)).toBe(true); + }); +}); + +describe('PCI DSS dashboard (#924)', () => { + it('builds a dashboard with the default sample posture', () => { + const dashboard = buildPciDssDashboard({ assessedBy: 'auditor-1' }); + + expect(dashboard.version).toBe('4.0'); + expect(dashboard.assessedBy).toBe('auditor-1'); + expect(dashboard.requirements).toHaveLength(12); + expect(dashboard.requirementStatusCounts.compliant).toBe(5); + expect(dashboard.requirementStatusCounts.in_place_with_deficiencies).toBe(3); + expect(dashboard.requirementStatusCounts.non_compliant).toBe(4); + expect(dashboard.controlStatusCounts.in_place).toBe(24); + expect(dashboard.controlStatusCounts.not_in_place).toBe(4); + expect(dashboard.controlStatusCounts.not_tested).toBe(4); + expect(dashboard.controlStatusCounts.not_applicable).toBe(1); + expect(dashboard.overallStatus).toBe('non_compliant'); + expect(dashboard.overallScore).toBeGreaterThan(0); + expect(dashboard.overallScore).toBeLessThan(100); + expect(dashboard.gaps).toHaveLength(8); + }); + + it('derives per-requirement compliance percentages', () => { + const requirements = assessPciDss(); + const req3 = requirements.find((r) => r.id === 3); + const req4 = requirements.find((r) => r.id === 4); + const req9 = requirements.find((r) => r.id === 9); + + expect(req3?.status).toBe('non_compliant'); + expect(req3?.compliancePercent).toBe(75); + expect(req4?.compliancePercent).toBe(50); + expect(req9?.status).toBe('in_place_with_deficiencies'); + expect(req9?.compliancePercent).toBe(0); + }); + + it('orders gaps by priority and supports filtering', () => { + const all = getPciDssGaps(); + const high = getPciDssGaps('high'); + expect(high.length).toBeGreaterThan(0); + expect(high.every((gap) => gap.priority === 'high')).toBe(true); + expect(all[0].priority).toBe('high'); + expect(getPciDssGaps('low')).toHaveLength(1); + }); +}); + +describe('PCI DSS control evidence (#924)', () => { + it('records a status change that clears a gap', () => { + const before = buildPciDssDashboard(); + expect(before.gaps.some((gap) => gap.controlId === '3.6.1')).toBe(true); + + const updated = setControlStatus('3.6.1', 'in_place', 'KMS rotation documented'); + expect(updated.status).toBe('in_place'); + expect(updated.evidence).toBe('KMS rotation documented'); + expect(getControlStatus('3.6.1')?.status).toBe('in_place'); + + const after = buildPciDssDashboard(); + expect(after.gaps.some((gap) => gap.controlId === '3.6.1')).toBe(false); + expect(after.requirements.find((r) => r.id === 3)?.status).toBe('compliant'); + }); + + it('becomes fully compliant once every gap is remediated', () => { + for (const gap of getPciDssGaps()) { + setControlStatus(gap.controlId, 'in_place'); + } + const dashboard = buildPciDssDashboard(); + expect(dashboard.gaps).toHaveLength(0); + expect(dashboard.overallStatus).toBe('compliant'); + expect(dashboard.overallScore).toBe(100); + expect(dashboard.requirementStatusCounts.non_compliant).toBe(0); + }); + + it('rejects unknown controls', () => { + expect(() => setControlStatus('99.9.9', 'in_place')).toThrow(/unknown/i); + expect(getControlStatus('99.9.9')).toBeNull(); + }); + + it('resets back to the default posture', () => { + setControlStatus('3.6.1', 'in_place'); + expect(buildPciDssDashboard().gaps).toHaveLength(7); + resetPciDssEvidence(); + expect(buildPciDssDashboard().gaps).toHaveLength(8); + }); +}); + +describe('PCI DSS evidence export (#924)', () => { + it('exports one CSV row per control', () => { + const csv = exportPciDssEvidenceCsv(); + const lines = csv.split('\n'); + const totalControls = PCI_DSS_REQUIREMENTS.reduce((sum, r) => sum + r.controls.length, 0); + expect(lines[0]).toContain('control_id'); + expect(lines).toHaveLength(totalControls + 1); + }); + + it('escapes evidence text in CSV output', () => { + setControlStatus('3.6.1', 'in_place', 'evidence with "quotes"'); + expect(exportPciDssEvidenceCsv()).toContain('"evidence with ""quotes"""'); + }); +}); diff --git a/backend/src/services/aml/__tests__/transaction-monitor.test.ts b/backend/src/services/aml/__tests__/transaction-monitor.test.ts new file mode 100644 index 00000000..7c6781d8 --- /dev/null +++ b/backend/src/services/aml/__tests__/transaction-monitor.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + AML_THRESHOLDS, + AmlTransaction, + clearAmlAlerts, + classifyRiskLevel, + createAmlAlert, + getAmlAlert, + getAmlAlerts, + getAmlRuleCatalogue, + isRoundAmount, + isUnusualHour, + monitorAmlTransactions, + resolveAmlAlert, + scoreAmlRisk, +} from '../transaction-monitor.js'; + +function baseTransaction(overrides: Partial = {}): AmlTransaction { + return { + transactionId: 'tx_1', + accountId: 'acct_1', + counterpartyId: 'cp_1', + amount: 250, + currency: 'USD', + timestamp: Date.parse('2026-01-02T14:30:00.000Z'), + ...overrides, + }; +} + +describe('AML risk scoring (#922)', () => { + beforeEach(() => clearAmlAlerts()); + + it('keeps a small, verified, known-counterparty payment at low risk', () => { + const assessment = scoreAmlRisk( + baseTransaction(), + { accountId: 'acct_1', kycVerified: true, knownCounterparties: ['cp_1'] }, + ); + + expect(assessment.level).toBe('low'); + expect(assessment.score).toBe(0); + expect(assessment.matches).toHaveLength(0); + expect(assessment.recommendedAction).toBe('allow'); + expect(assessment.requiresSarFiling).toBe(false); + }); + + it('flags near-threshold amounts as potential structuring', () => { + const assessment = scoreAmlRisk( + baseTransaction({ amount: 9_500 }), + { accountId: 'acct_1', kycVerified: true, knownCounterparties: ['cp_1'] }, + ); + + expect(assessment.matches.map((m) => m.ruleId)).toContain('structuring'); + expect(assessment.score).toBeGreaterThanOrEqual(35); + expect(['medium', 'high', 'critical']).toContain(assessment.level); + }); + + it('escalates sanctioned counterparties to critical and requires a SAR', () => { + const assessment = scoreAmlRisk( + baseTransaction({ counterpartySanctioned: true }), + { accountId: 'acct_1', kycVerified: true, knownCounterparties: ['cp_1'] }, + ); + + expect(assessment.level).toBe('critical'); + expect(assessment.requiresSarFiling).toBe(true); + expect(assessment.recommendedAction).toBe('block_and_report'); + expect(assessment.matches.some((m) => m.ruleId === 'sanctions_exposure')).toBe(true); + }); + + it('aggregates multiple weak signals into a higher score', () => { + const assessment = scoreAmlRisk( + baseTransaction({ + amount: 25_000, + structuringSignals: 3, + originCountry: 'XX', + timestamp: Date.parse('2026-01-02T02:00:00.000Z'), + counterpartyIsPep: true, + }), + { + accountId: 'acct_1', + kycVerified: false, + accountAgeDays: 5, + baselineAmount: 1_000, + transactionsLastHour: 12, + transactionsLast24h: 45, + distinctCounterpartiesLast24h: 15, + highRiskCountries: ['XX'], + }, + ); + + expect(assessment.score).toBe(100); + expect(assessment.level).toBe('critical'); + expect(new Set(assessment.matches.map((m) => m.ruleId)).size).toBe(assessment.matches.length); + }); + + it('never exceeds a score of 100', () => { + const assessment = scoreAmlRisk( + baseTransaction({ + amount: 1_000_000, + counterpartySanctioned: true, + counterpartyIsPep: true, + structuringSignals: 5, + }), + { accountId: 'acct_1', kycVerified: false, transactionsLastHour: 100 }, + ); + expect(assessment.score).toBeLessThanOrEqual(100); + }); + + it('classifies scores into the documented bands', () => { + expect(classifyRiskLevel(0)).toBe('low'); + expect(classifyRiskLevel(19)).toBe('low'); + expect(classifyRiskLevel(20)).toBe('medium'); + expect(classifyRiskLevel(39)).toBe('medium'); + expect(classifyRiskLevel(40)).toBe('high'); + expect(classifyRiskLevel(69)).toBe('high'); + expect(classifyRiskLevel(70)).toBe('critical'); + }); + + it('exposes the rule catalogue without the evaluator functions', () => { + const catalogue = getAmlRuleCatalogue(); + expect(catalogue.length).toBeGreaterThan(5); + expect(catalogue.every((rule) => typeof rule.id === 'string' && rule.weight > 0)).toBe(true); + expect('evaluate' in catalogue[0]).toBe(false); + }); + + it('exposes stable thresholds', () => { + expect(AML_THRESHOLDS.ctrAmount).toBe(10_000); + expect(AML_THRESHOLDS.structuringBand).toBeLessThan(1); + }); +}); + +describe('AML helper predicates (#922)', () => { + it('recognises unusual hours in UTC', () => { + expect(isUnusualHour(Date.parse('2026-01-02T02:00:00.000Z'))).toBe(true); + expect(isUnusualHour(Date.parse('2026-01-02T14:00:00.000Z'))).toBe(false); + }); + + it('recognises large round amounts only', () => { + expect(isRoundAmount(5_000)).toBe(true); + expect(isRoundAmount(5_500)).toBe(false); + expect(isRoundAmount(500)).toBe(false); + expect(isRoundAmount(0)).toBe(false); + }); +}); + +describe('AML batch monitoring and alerting (#922)', () => { + beforeEach(() => clearAmlAlerts()); + + it('creates alerts only for high and critical assessments', () => { + const quiet = baseTransaction({ transactionId: 'tx_quiet' }); + const sanctioned = baseTransaction({ transactionId: 'tx_bad', counterpartySanctioned: true }); + + const result = monitorAmlTransactions( + [quiet, sanctioned], + { acct_1: { accountId: 'acct_1', kycVerified: true, knownCounterparties: ['cp_1'] } }, + ); + + expect(result.assessments).toHaveLength(2); + expect(result.alerts).toHaveLength(1); + expect(result.alerts[0].transactionId).toBe('tx_bad'); + expect(result.summary.byLevel.critical).toBe(1); + expect(result.summary.byLevel.low).toBe(1); + expect(result.summary.highestScore).toBeGreaterThanOrEqual(70); + expect(result.summary.averageScore).toBeGreaterThan(0); + }); + + it('lists, filters and resolves alerts', () => { + const assessment = scoreAmlRisk( + baseTransaction({ transactionId: 'tx_alert', counterpartySanctioned: true }), + { accountId: 'acct_1' }, + ); + const alert = createAmlAlert(assessment); + + expect(getAmlAlerts()).toHaveLength(1); + expect(getAmlAlerts({ status: 'open' })).toHaveLength(1); + expect(getAmlAlerts({ status: 'resolved' })).toHaveLength(0); + expect(getAmlAlerts({ accountId: 'other' })).toHaveLength(0); + expect(getAmlAlert(alert.id)?.id).toBe(alert.id); + expect(getAmlAlert('missing')).toBeNull(); + + const resolved = resolveAmlAlert(alert.id, 'resolved', { reviewedBy: 'analyst', resolution: 'cleared' }); + expect(resolved.status).toBe('resolved'); + expect(resolved.reviewedBy).toBe('analyst'); + expect(getAmlAlerts({ status: 'open' })).toHaveLength(0); + }); + + it('rejects resolution of an unknown alert', () => { + expect(() => resolveAmlAlert('missing', 'resolved')).toThrow(/not found/i); + }); + + it('rejects resolving an already finalised alert', () => { + const assessment = scoreAmlRisk(baseTransaction({ counterpartySanctioned: true }), { accountId: 'acct_1' }); + const alert = createAmlAlert(assessment); + resolveAmlAlert(alert.id, 'dismissed'); + expect(() => resolveAmlAlert(alert.id, 'resolved')).toThrow(/already/i); + }); + + it('handles an empty batch', () => { + const result = monitorAmlTransactions([]); + expect(result.assessments).toHaveLength(0); + expect(result.alerts).toHaveLength(0); + expect(result.summary.averageScore).toBe(0); + }); +}); diff --git a/backend/src/services/aml/transaction-monitor.ts b/backend/src/services/aml/transaction-monitor.ts new file mode 100644 index 00000000..642fc0d3 --- /dev/null +++ b/backend/src/services/aml/transaction-monitor.ts @@ -0,0 +1,631 @@ +/** + * AML Transaction Monitoring with Risk Scoring — Issue #922 + * + * Applies a deterministic, explainable rule engine to every payment that flows + * through the platform and produces a 0-100 risk score plus the list of rules + * that fired. High and critical assessments are turned into AML alerts that can + * be triaged through the API. + * + * The design mirrors `services/fraud-detection.ts`: pure scoring functions with + * no external I/O, an in-memory alert store, and small composable helpers so the + * math can be unit tested without a database. + */ + +import { randomUUID } from 'node:crypto'; + +export type AmlRiskLevel = 'low' | 'medium' | 'high' | 'critical'; +export type AmlAlertStatus = 'open' | 'under_review' | 'resolved' | 'dismissed'; +export type AmlAlertSeverity = 'medium' | 'high' | 'critical'; + +export interface AmlTransaction { + transactionId: string; + accountId: string; + counterpartyId?: string; + amount: number; + currency: string; + /** Epoch milliseconds or ISO-8601 string. */ + timestamp: number | string; + direction?: 'inbound' | 'outbound'; + channel?: 'crypto' | 'bank' | 'card' | 'wallet'; + originCountry?: string; + destinationCountry?: string; + counterpartySanctioned?: boolean; + counterpartyIsPep?: boolean; + /** Number of near-threshold transactions observed from the same account. */ + structuringSignals?: number; + metadata?: Record; +} + +export interface AmlAccountContext { + accountId: string; + accountAgeDays?: number; + kycVerified?: boolean; + customerRiskRating?: 'low' | 'medium' | 'high'; + transactionsLastHour?: number; + transactionsLast24h?: number; + totalAmountLast24h?: number; + /** Historical average transaction amount for this account. */ + baselineAmount?: number; + /** Wave of different counterparties in the last 24h. */ + distinctCounterpartiesLast24h?: number; + /** Countries considered high risk by the institution's risk appetite. */ + highRiskCountries?: string[]; + /** Established counterparties that never require heightened scrutiny. */ + knownCounterparties?: string[]; +} + +export interface AmlRuleMatch { + ruleId: string; + name: string; + description: string; + weight: number; + severity: AmlAlertSeverity; + details?: Record; +} + +export interface AmlAssessment { + transactionId: string; + accountId: string; + score: number; + level: AmlRiskLevel; + matches: AmlRuleMatch[]; + recommendedAction: 'allow' | 'monitor' | 'review' | 'block_and_report'; + requiresSarFiling: boolean; + assessedAt: string; +} + +export interface AmlAlert { + id: string; + transactionId: string; + accountId: string; + severity: AmlAlertSeverity; + status: AmlAlertStatus; + score: number; + level: AmlRiskLevel; + reasons: string[]; + matches: AmlRuleMatch[]; + createdAt: string; + updatedAt: string; + reviewedBy?: string; + resolution?: string; +} + +export interface AmlMonitoringSummary { + assessed: number; + alertsCreated: number; + byLevel: Record; + highestScore: number; + averageScore: number; + generatedAt: string; +} + +export interface AmlRuleContext { + transaction: AmlTransaction; + account?: AmlAccountContext; +} + +export interface AmlRule { + id: string; + name: string; + description: string; + weight: number; + severity: AmlAlertSeverity; + evaluate: (context: AmlRuleContext) => AmlRuleMatch | null; +} + +// ─── Thresholds ─────────────────────────────────────────────────────────────── + +export const AML_THRESHOLDS = { + /** FinCEN CTR reporting threshold (USD). */ + ctrAmount: 10_000, + /** Fraction of the CTR threshold treated as structuring behaviour. */ + structuringBand: 0.8, + highAmount: 25_000, + velocityPerHour: 10, + velocityPer24h: 40, + distinctCounterparties24h: 10, + newAccountDays: 30, + amountBaselineMultiplier: 5, +} as const; + +const LEVEL_BANDS: Array<{ level: AmlRiskLevel; min: number }> = [ + { level: 'critical', min: 70 }, + { level: 'high', min: 40 }, + { level: 'medium', min: 20 }, + { level: 'low', min: 0 }, +]; + +const ALERT_SEVERITY_BY_LEVEL: Record = { + low: 'medium', + medium: 'medium', + high: 'high', + critical: 'critical', +}; + +function toEpochMs(timestamp: number | string): number { + if (typeof timestamp === 'number') return timestamp; + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? Date.now() : parsed; +} + +function upper(value?: string): string { + return (value ?? '').trim().toUpperCase(); +} + +/** + * Returns true when the timestamp falls outside the institution's + * business-hours window (local `0 <= hour < 6`). + */ +export function isUnusualHour(timestamp: number | string): boolean { + const hour = new Date(toEpochMs(timestamp)).getUTCHours(); + return hour >= 0 && hour < 6; +} + +export function isRoundAmount(amount: number): boolean { + if (!Number.isFinite(amount) || amount <= 0) return false; + return amount >= 1_000 && amount % 1_000 === 0; +} + +// ─── Rule catalogue ─────────────────────────────────────────────────────────── + +const highRiskCountryRule: AmlRule = { + id: 'high_risk_jurisdiction', + name: 'High-risk jurisdiction exposure', + description: 'Origin or destination country is on the institution high-risk list', + weight: 20, + severity: 'high', + evaluate: ({ transaction, account }) => { + const highRisk = (account?.highRiskCountries ?? []).map(upper); + if (highRisk.length === 0) return null; + const touched = [transaction.originCountry, transaction.destinationCountry] + .filter((country): country is string => Boolean(country)) + .map(upper) + .filter((country) => highRisk.includes(country)); + if (touched.length === 0) return null; + return { + ruleId: 'high_risk_jurisdiction', + name: highRiskCountryRule.name, + description: highRiskCountryRule.description, + weight: highRiskCountryRule.weight, + severity: highRiskCountryRule.severity, + details: { countries: touched }, + }; + }, +}; + +export const AML_RULES: AmlRule[] = [ + { + id: 'sanctions_exposure', + name: 'Sanctions exposure', + description: 'Counterparty is on a sanctions watchlist', + weight: 70, + severity: 'critical', + evaluate: ({ transaction }) => { + if (!transaction.counterpartySanctioned) return null; + return { + ruleId: 'sanctions_exposure', + name: 'Sanctions exposure', + description: 'Counterparty is on a sanctions watchlist', + weight: 70, + severity: 'critical', + details: { counterpartyId: transaction.counterpartyId }, + }; + }, + }, + { + id: 'pep_exposure', + name: 'Politically exposed person', + description: 'Counterparty is a politically exposed person', + weight: 30, + severity: 'high', + evaluate: ({ transaction }) => { + if (!transaction.counterpartyIsPep) return null; + return { + ruleId: 'pep_exposure', + name: 'Politically exposed person', + description: 'Counterparty is a politically exposed person', + weight: 30, + severity: 'high', + details: { counterpartyId: transaction.counterpartyId }, + }; + }, + }, + { + id: 'structuring', + name: 'Potential structuring', + description: 'Amounts sit just below the reporting threshold or repeated near-threshold activity was observed', + weight: 35, + severity: 'high', + evaluate: ({ transaction }) => { + const amount = transaction.amount; + const band = AML_THRESHOLDS.ctrAmount * AML_THRESHOLDS.structuringBand; + const withinBand = amount >= band && amount < AML_THRESHOLDS.ctrAmount; + const repeated = (transaction.structuringSignals ?? 0) >= 2; + if (!withinBand && !repeated) return null; + return { + ruleId: 'structuring', + name: 'Potential structuring', + description: 'Near-threshold pattern consistent with structuring', + weight: 35, + severity: 'high', + details: { + amount, + ctrThreshold: AML_THRESHOLDS.ctrAmount, + withinBand, + structuringSignals: transaction.structuringSignals ?? 0, + }, + }; + }, + }, + { + id: 'ctr_threshold_breach', + name: 'CTR reporting threshold', + description: 'Transaction meets or exceeds the CTR reporting threshold', + weight: 20, + severity: 'medium', + evaluate: ({ transaction }) => { + if (transaction.amount < AML_THRESHOLDS.ctrAmount) return null; + return { + ruleId: 'ctr_threshold_breach', + name: 'CTR reporting threshold', + description: 'Transaction meets or exceeds the CTR reporting threshold', + weight: 20, + severity: 'medium', + details: { amount: transaction.amount, threshold: AML_THRESHOLDS.ctrAmount }, + }; + }, + }, + { + id: 'unusually_large_amount', + name: 'Unusually large amount', + description: 'Amount exceeds the institution high-value monitoring threshold', + weight: 15, + severity: 'high', + evaluate: ({ transaction }) => { + if (transaction.amount < AML_THRESHOLDS.highAmount) return null; + return { + ruleId: 'unusually_large_amount', + name: 'Unusually large amount', + description: 'Amount exceeds the high-value monitoring threshold', + weight: 15, + severity: 'high', + details: { amount: transaction.amount, threshold: AML_THRESHOLDS.highAmount }, + }; + }, + }, + { + id: 'velocity_per_hour', + name: 'Transaction velocity (hourly)', + description: 'Too many transactions from the account within the last hour', + weight: 25, + severity: 'high', + evaluate: ({ account }) => { + const count = account?.transactionsLastHour ?? 0; + if (count < AML_THRESHOLDS.velocityPerHour) return null; + return { + ruleId: 'velocity_per_hour', + name: 'Transaction velocity (hourly)', + description: 'Hourly transaction velocity limit exceeded', + weight: 25, + severity: 'high', + details: { count, threshold: AML_THRESHOLDS.velocityPerHour }, + }; + }, + }, + { + id: 'velocity_per_day', + name: 'Transaction velocity (daily)', + description: 'Too many transactions from the account within the last 24 hours', + weight: 15, + severity: 'medium', + evaluate: ({ account }) => { + const count = account?.transactionsLast24h ?? 0; + if (count < AML_THRESHOLDS.velocityPer24h) return null; + return { + ruleId: 'velocity_per_day', + name: 'Transaction velocity (daily)', + description: 'Daily transaction velocity limit exceeded', + weight: 15, + severity: 'medium', + details: { count, threshold: AML_THRESHOLDS.velocityPer24h }, + }; + }, + }, + { + id: 'fan_out', + name: 'Rapid fan-out to many counterparties', + description: 'Account interacted with an abnormal number of distinct counterparties in 24h', + weight: 20, + severity: 'high', + evaluate: ({ account }) => { + const count = account?.distinctCounterpartiesLast24h ?? 0; + if (count < AML_THRESHOLDS.distinctCounterparties24h) return null; + return { + ruleId: 'fan_out', + name: 'Rapid fan-out to many counterparties', + description: 'Abnormal number of distinct counterparties within 24h', + weight: 20, + severity: 'high', + details: { count, threshold: AML_THRESHOLDS.distinctCounterparties24h }, + }; + }, + }, + { + id: 'amount_spike', + name: 'Amount spike versus baseline', + description: 'Transaction amount is far above the account historical baseline', + weight: 20, + severity: 'high', + evaluate: ({ transaction, account }) => { + const baseline = account?.baselineAmount ?? 0; + if (baseline <= 0) return null; + const ratio = transaction.amount / baseline; + if (ratio < AML_THRESHOLDS.amountBaselineMultiplier) return null; + return { + ruleId: 'amount_spike', + name: 'Amount spike versus baseline', + description: 'Transaction is far above the account baseline', + weight: 20, + severity: 'high', + details: { amount: transaction.amount, baseline, ratio: Number(ratio.toFixed(2)) }, + }; + }, + }, + { + id: 'unverified_account', + name: 'Unverified account', + description: 'Account has not completed KYC verification', + weight: 25, + severity: 'high', + evaluate: ({ account }) => { + if (!account || account.kycVerified !== false) return null; + return { + ruleId: 'unverified_account', + name: 'Unverified account', + description: 'Account has not completed KYC verification', + weight: 25, + severity: 'high', + details: { accountId: account.accountId }, + }; + }, + }, + { + id: 'new_account_high_value', + name: 'New account with high-value movement', + description: 'Recently opened account is moving large amounts', + weight: 15, + severity: 'medium', + evaluate: ({ transaction, account }) => { + const age = account?.accountAgeDays; + if (age === undefined || age >= AML_THRESHOLDS.newAccountDays) return null; + if (transaction.amount < AML_THRESHOLDS.ctrAmount) return null; + return { + ruleId: 'new_account_high_value', + name: 'New account with high-value movement', + description: 'New account is moving high-value amounts', + weight: 15, + severity: 'medium', + details: { accountAgeDays: age, amount: transaction.amount }, + }; + }, + }, + { + id: 'unusual_hour', + name: 'Unusual transaction hour', + description: 'Transaction executed outside the account normal activity window', + weight: 10, + severity: 'medium', + evaluate: ({ transaction }) => { + if (!isUnusualHour(transaction.timestamp)) return null; + return { + ruleId: 'unusual_hour', + name: 'Unusual transaction hour', + description: 'Transaction executed outside business hours', + weight: 10, + severity: 'medium', + details: { timestamp: new Date(toEpochMs(transaction.timestamp)).toISOString() }, + }; + }, + }, + { + id: 'round_amount', + name: 'Large round amount', + description: 'Large round-number transfer, a common laundering signal', + weight: 5, + severity: 'medium', + evaluate: ({ transaction }) => { + if (!isRoundAmount(transaction.amount)) return null; + return { + ruleId: 'round_amount', + name: 'Large round amount', + description: 'Large round-number transfer', + weight: 5, + severity: 'medium', + details: { amount: transaction.amount }, + }; + }, + }, + { + id: 'unknown_counterparty', + name: 'First-time counterparty', + description: 'Counterparty has never transacted with this account before', + weight: 10, + severity: 'medium', + evaluate: ({ transaction, account }) => { + if (!account?.knownCounterparties) return null; + if (!transaction.counterpartyId) return null; + if (account.knownCounterparties.includes(transaction.counterpartyId)) return null; + return { + ruleId: 'unknown_counterparty', + name: 'First-time counterparty', + description: 'First-time counterparty for this account', + weight: 10, + severity: 'medium', + details: { counterpartyId: transaction.counterpartyId }, + }; + }, + }, + highRiskCountryRule, +]; + +// ─── Scoring ────────────────────────────────────────────────────────────────── + +export function classifyRiskLevel(score: number): AmlRiskLevel { + for (const band of LEVEL_BANDS) { + if (score >= band.min) return band.level; + } + return 'low'; +} + +function recommendedActionFor(level: AmlRiskLevel, matches: AmlRuleMatch[]): AmlAssessment['recommendedAction'] { + const sanctioned = matches.some((m) => m.ruleId === 'sanctions_exposure'); + if (sanctioned || level === 'critical') return 'block_and_report'; + if (level === 'high') return 'review'; + if (level === 'medium') return 'monitor'; + return 'allow'; +} + +/** + * Score a single transaction against the AML rule catalogue. + */ +export function scoreAmlRisk( + transaction: AmlTransaction, + account?: AmlAccountContext, +): AmlAssessment { + const matches: AmlRuleMatch[] = []; + for (const rule of AML_RULES) { + const match = rule.evaluate({ transaction, account }); + if (match) matches.push(match); + } + + const rawScore = matches.reduce((sum, match) => sum + match.weight, 0); + const score = Math.min(100, rawScore); + const level = classifyRiskLevel(score); + const sanctioned = matches.some((m) => m.ruleId === 'sanctions_exposure'); + + return { + transactionId: transaction.transactionId, + accountId: transaction.accountId, + score, + level, + matches, + recommendedAction: recommendedActionFor(level, matches), + requiresSarFiling: sanctioned || level === 'critical', + assessedAt: new Date().toISOString(), + }; +} + +// ─── Alert store ────────────────────────────────────────────────────────────── + +const alerts = new Map(); + +export function createAmlAlert(assessment: AmlAssessment): AmlAlert { + const severity = ALERT_SEVERITY_BY_LEVEL[assessment.level]; + const now = new Date().toISOString(); + const alert: AmlAlert = { + id: `aml_${randomUUID()}`, + transactionId: assessment.transactionId, + accountId: assessment.accountId, + severity, + status: 'open', + score: assessment.score, + level: assessment.level, + reasons: assessment.matches.map((m) => m.name), + matches: assessment.matches, + createdAt: now, + updatedAt: now, + }; + alerts.set(alert.id, alert); + return alert; +} + +export function getAmlAlerts(filter?: { + status?: AmlAlertStatus; + severity?: AmlAlertSeverity; + accountId?: string; +}): AmlAlert[] { + let list = Array.from(alerts.values()); + if (filter?.status) list = list.filter((a) => a.status === filter.status); + if (filter?.severity) list = list.filter((a) => a.severity === filter.severity); + if (filter?.accountId) list = list.filter((a) => a.accountId === filter.accountId); + return list.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); +} + +export function getAmlAlert(id: string): AmlAlert | null { + return alerts.get(id) ?? null; +} + +export function resolveAmlAlert( + id: string, + status: Exclude, + options: { reviewedBy?: string; resolution?: string } = {}, +): AmlAlert { + const alert = alerts.get(id); + if (!alert) throw new Error(`AML alert not found: ${id}`); + if (alert.status === 'resolved' || alert.status === 'dismissed') { + throw new Error(`AML alert ${id} is already ${alert.status}`); + } + alert.status = status; + alert.reviewedBy = options.reviewedBy; + alert.resolution = options.resolution; + alert.updatedAt = new Date().toISOString(); + alerts.set(id, alert); + return alert; +} + +/** Test/ops helper to reset the in-memory alert store. */ +export function clearAmlAlerts(): void { + alerts.clear(); +} + +// ─── Batch monitoring ───────────────────────────────────────────────────────── + +const LEVEL_ORDER: AmlRiskLevel[] = ['low', 'medium', 'high', 'critical']; + +export function monitorAmlTransactions( + transactions: AmlTransaction[], + accountContexts: Record = {}, +): { assessments: AmlAssessment[]; alerts: AmlAlert[]; summary: AmlMonitoringSummary } { + const assessments: AmlAssessment[] = []; + const created: AmlAlert[] = []; + const byLevel: Record = { low: 0, medium: 0, high: 0, critical: 0 }; + + let totalScore = 0; + let highestScore = 0; + + for (const transaction of transactions) { + const context = accountContexts[transaction.accountId]; + const assessment = scoreAmlRisk(transaction, context); + assessments.push(assessment); + byLevel[assessment.level] += 1; + totalScore += assessment.score; + highestScore = Math.max(highestScore, assessment.score); + + if (LEVEL_ORDER.indexOf(assessment.level) >= LEVEL_ORDER.indexOf('high')) { + created.push(createAmlAlert(assessment)); + } + } + + return { + assessments, + alerts: created, + summary: { + assessed: assessments.length, + alertsCreated: created.length, + byLevel, + highestScore, + averageScore: assessments.length > 0 ? Number((totalScore / assessments.length).toFixed(2)) : 0, + generatedAt: new Date().toISOString(), + }, + }; +} + +export function getAmlRuleCatalogue(): Array> { + return AML_RULES.map(({ id, name, description, weight, severity }) => ({ + id, + name, + description, + weight, + severity, + })); +} diff --git a/backend/src/services/pci-dss.ts b/backend/src/services/pci-dss.ts new file mode 100644 index 00000000..40c45bd2 --- /dev/null +++ b/backend/src/services/pci-dss.ts @@ -0,0 +1,441 @@ +/** + * PCI DSS Compliance Dashboard — Issue #924 + * + * Models PCI DSS v4.0 as a catalogue of the twelve top-level requirements, each + * backed by concrete controls with an evidence-backed status. Aggregating the + * control statuses produces a per-requirement compliance percentage, a weighted + * overall score, and a prioritised list of gaps with remediation guidance. + * + * Statuses are held in memory and seeded with a realistic sample posture so the + * dashboard renders immediately; call `setControlStatus()` (or the API) as real + * evidence is gathered. + */ + +export type PciDssControlStatus = 'in_place' | 'not_in_place' | 'not_tested' | 'not_applicable'; +export type PciDssRequirementStatus = + | 'compliant' + | 'in_place_with_deficiencies' + | 'non_compliant' + | 'not_applicable'; +export type PciDssPriority = 'high' | 'medium' | 'low'; + +export type PciDssCategory = + | 'network_security' + | 'secure_configuration' + | 'account_data_protection' + | 'cryptography' + | 'malware_protection' + | 'secure_development' + | 'access_control' + | 'authentication' + | 'physical_security' + | 'logging_monitoring' + | 'security_testing' + | 'security_policy'; + +export interface PciDssControlDefinition { + id: string; + title: string; + requirementId: number; + priority: PciDssPriority; + /** Framework/regulatory reference, e.g. "PCI DSS v4.0 Req 3.5.1". */ + reference: string; + remediation: string; + defaultStatus: PciDssControlStatus; +} + +export interface PciDssRequirementDefinition { + id: number; + title: string; + category: PciDssCategory; + /** Relative weight used when computing the overall score. */ + weight: number; + controls: PciDssControlDefinition[]; +} + +export interface PciDssGap { + controlId: string; + controlTitle: string; + requirementId: number; + requirementTitle: string; + status: Exclude; + priority: PciDssPriority; + reference: string; + remediation: string; +} + +export interface PciDssControlAssessment extends PciDssControlDefinition { + status: PciDssControlStatus; + evidence?: string; + testedAt?: string; +} + +export interface PciDssRequirementAssessment { + id: number; + title: string; + category: PciDssCategory; + weight: number; + status: PciDssRequirementStatus; + compliancePercent: number; + controls: PciDssControlAssessment[]; + gaps: PciDssGap[]; +} + +export interface PciDssDashboard { + version: '4.0'; + generatedAt: string; + overallScore: number; + overallStatus: PciDssRequirementStatus; + assessedBy?: string; + requirementStatusCounts: Record; + controlStatusCounts: Record; + evidenceCoverage: number; + requirements: PciDssRequirementAssessment[]; + gaps: PciDssGap[]; +} + +// ─── Catalogue ──────────────────────────────────────────────────────────────── + +function control( + id: string, + requirementId: number, + title: string, + priority: PciDssPriority, + reference: string, + remediation: string, + defaultStatus: PciDssControlStatus, +): PciDssControlDefinition { + return { id, requirementId, title, priority, reference, remediation, defaultStatus }; +} + +export const PCI_DSS_REQUIREMENTS: PciDssRequirementDefinition[] = [ + { + id: 1, + title: 'Install and Maintain Network Security Controls', + category: 'network_security', + weight: 1, + controls: [ + control('1.2.1', 1, 'Network security controls configuration standards are defined and applied', 'high', 'PCI DSS v4.0 Req 1.2.1', 'Document and apply firewall/router configuration standards', 'in_place'), + control('1.3.1', 1, 'Inbound traffic to the CDE is restricted to necessary services', 'high', 'PCI DSS v4.0 Req 1.3.1', 'Restrict inbound CDE traffic to required ports and services', 'in_place'), + control('1.4.2', 1, 'Outbound traffic from the CDE is restricted', 'medium', 'PCI DSS v4.0 Req 1.4.2', 'Restrict outbound CDE traffic to approved destinations', 'in_place'), + ], + }, + { + id: 2, + title: 'Apply Secure Configurations to All System Components', + category: 'secure_configuration', + weight: 1, + controls: [ + control('2.2.1', 2, 'Configuration standards are defined, implemented and maintained', 'high', 'PCI DSS v4.0 Req 2.2.1', 'Harden system baselines and review against CIS benchmarks', 'in_place'), + control('2.2.7', 2, 'Default accounts and passwords are removed or disabled', 'high', 'PCI DSS v4.0 Req 2.2.7', 'Disable/rename default vendor accounts before deployment', 'in_place'), + control('2.3.1', 2, 'Wireless environments are secure by default', 'low', 'PCI DSS v4.0 Req 2.3.1', 'Update AP firmware and rotate wireless keys on change', 'not_tested'), + ], + }, + { + id: 3, + title: 'Protect Stored Account Data', + category: 'account_data_protection', + weight: 2, + controls: [ + control('3.2.1', 3, 'Account data storage is kept to a minimum', 'high', 'PCI DSS v4.0 Req 3.2.1', 'Implement data-retention and purging before storage', 'in_place'), + control('3.5.1', 3, 'Primary account numbers are rendered unreadable wherever stored', 'high', 'PCI DSS v4.0 Req 3.5.1', 'Apply strong cryptography (AES-256-GCM) to stored PANs', 'in_place'), + control('3.4.2', 3, 'Disk-level encryption is used for stored account data', 'medium', 'PCI DSS v4.0 Req 3.4.2', 'Enable full-disk/volume encryption for CDE databases', 'in_place'), + control('3.6.1', 3, 'Cryptographic keys are protected and rotated', 'high', 'PCI DSS v4.0 Req 3.6.1', 'Adopt a documented key-management and rotation policy', 'not_in_place'), + ], + }, + { + id: 4, + title: 'Protect Cardholder Data with Strong Cryptography During Transmission', + category: 'cryptography', + weight: 2, + controls: [ + control('4.2.1', 4, 'Strong cryptography protects PANs during transmission over open networks', 'high', 'PCI DSS v4.0 Req 4.2.1', 'Enforce TLS 1.2+ and disable weak cipher suites', 'in_place'), + control('4.2.1.1', 4, 'Certificates and keys used for transmission are current and trusted', 'medium', 'PCI DSS v4.0 Req 4.2.1.1', 'Automate certificate expiry monitoring and renewal', 'not_in_place'), + ], + }, + { + id: 5, + title: 'Protect All Systems and Networks from Malicious Software', + category: 'malware_protection', + weight: 1, + controls: [ + control('5.2.1', 5, 'Malware protection is deployed on all applicable system components', 'high', 'PCI DSS v4.0 Req 5.2.1', 'Deploy and maintain anti-malware on all applicable systems', 'in_place'), + control('5.3.1', 5, 'Anti-malware mechanisms are kept current and actively running', 'medium', 'PCI DSS v4.0 Req 5.3.1', 'Enable automatic signature updates and health monitoring', 'in_place'), + ], + }, + { + id: 6, + title: 'Develop and Maintain Secure Systems and Software', + category: 'secure_development', + weight: 2, + controls: [ + control('6.2.1', 6, 'Secure coding practices are defined and applied', 'high', 'PCI DSS v4.0 Req 6.2.1', 'Adopt the OWASP secure coding guidelines in CI', 'in_place'), + control('6.3.3', 6, 'Security patches are installed within defined timeframes', 'high', 'PCI DSS v4.0 Req 6.3.3', 'Track patching SLAs and remediate critical CVEs quickly', 'not_tested'), + control('6.4.1', 6, 'Public web applications are protected by a WAF', 'high', 'PCI DSS v4.0 Req 6.4.1', 'Deploy a WAF in front of internet-facing applications', 'in_place'), + ], + }, + { + id: 7, + title: 'Restrict Access to System Components and Cardholder Data by Business Need to Know', + category: 'access_control', + weight: 1, + controls: [ + control('7.2.1', 7, 'Access control model is defined with least privilege', 'high', 'PCI DSS v4.0 Req 7.2.1', 'Define and enforce least-privilege role assignments', 'in_place'), + control('7.2.4', 7, 'Access is reviewed at least every six months', 'medium', 'PCI DSS v4.0 Req 7.2.4', 'Schedule recurring access recertification', 'in_place'), + ], + }, + { + id: 8, + title: 'Identify Users and Authenticate Access to System Components', + category: 'authentication', + weight: 2, + controls: [ + control('8.2.1', 8, 'All users are assigned a unique ID', 'high', 'PCI DSS v4.0 Req 8.2.1', 'Eliminate shared accounts in the CDE', 'in_place'), + control('8.4.2', 8, 'Multi-factor authentication is enforced for all access into the CDE', 'high', 'PCI DSS v4.0 Req 8.4.2', 'Enforce MFA (TOTP/WebAuthn) for CDE access', 'in_place'), + control('8.3.6', 8, 'Passwords meet minimum complexity requirements', 'medium', 'PCI DSS v4.0 Req 8.3.6', 'Enforce length/complexity and breach-list checks', 'in_place'), + ], + }, + { + id: 9, + title: 'Restrict Physical Access to Cardholder Data', + category: 'physical_security', + weight: 1, + controls: [ + control('9.2.1', 9, 'Appropriate facility entry controls are in place', 'medium', 'PCI DSS v4.0 Req 9.2.1', 'Document and enforce physical entry controls', 'not_applicable'), + control('9.4.1', 9, 'Media with cardholder data is securely stored and destroyed', 'medium', 'PCI DSS v4.0 Req 9.4.1', 'Apply media handling and destruction procedures', 'not_tested'), + ], + }, + { + id: 10, + title: 'Log and Monitor All Access to System Components and Cardholder Data', + category: 'logging_monitoring', + weight: 2, + controls: [ + control('10.2.1', 10, 'Audit logs capture access to system components and cardholder data', 'high', 'PCI DSS v4.0 Req 10.2.1', 'Enable immutable audit logging for CDE access', 'in_place'), + control('10.3.3', 10, 'Audit logs are protected from destruction and modification', 'high', 'PCI DSS v4.0 Req 10.3.3', 'Write logs to append-only/WORM storage', 'in_place'), + control('10.7.2', 10, 'Anomalies and suspicious activity trigger alerts', 'high', 'PCI DSS v4.0 Req 10.7.2', 'Wire SIEM detections to on-call alerting', 'in_place'), + ], + }, + { + id: 11, + title: 'Test Security of Systems and Networks Regularly', + category: 'security_testing', + weight: 2, + controls: [ + control('11.3.1', 11, 'Internal vulnerability scans are performed quarterly', 'high', 'PCI DSS v4.0 Req 11.3.1', 'Schedule recurring internal vulnerability scans', 'in_place'), + control('11.4.1', 11, 'External penetration testing is performed annually', 'high', 'PCI DSS v4.0 Req 11.4.1', 'Engage an external pen-test provider at least annually', 'not_in_place'), + control('11.5.1', 11, 'Changes to the CDE are detected and alerted', 'medium', 'PCI DSS v4.0 Req 11.5.1', 'Deploy file-integrity monitoring for CDE systems', 'not_tested'), + ], + }, + { + id: 12, + title: 'Support Information Security with Organizational Policies and Programs', + category: 'security_policy', + weight: 1, + controls: [ + control('12.1.1', 12, 'An overall information security policy is established and reviewed', 'medium', 'PCI DSS v4.0 Req 12.1.1', 'Publish and review the security policy annually', 'in_place'), + control('12.6.1', 12, 'A security awareness program is in place', 'medium', 'PCI DSS v4.0 Req 12.6.1', 'Run recurring security-awareness training', 'in_place'), + control('12.10.1', 12, 'Incident response plan exists and is tested', 'high', 'PCI DSS v4.0 Req 12.10.1', 'Maintain and exercise the incident-response plan', 'not_in_place'), + ], + }, +]; + +// ─── In-memory evidence store ───────────────────────────────────────────────── + +interface ControlState { + status: PciDssControlStatus; + evidence?: string; + testedAt?: string; +} + +const controlState = new Map(); + +function seedDefaults(): void { + controlState.clear(); + for (const requirement of PCI_DSS_REQUIREMENTS) { + for (const item of requirement.controls) { + controlState.set(item.id, { status: item.defaultStatus }); + } + } +} + +seedDefaults(); + +export function resetPciDssEvidence(): void { + seedDefaults(); +} + +export function setControlStatus( + controlId: string, + status: PciDssControlStatus, + evidence?: string, +): PciDssControlAssessment { + const definition = PCI_DSS_REQUIREMENTS.flatMap((r) => r.controls).find((c) => c.id === controlId); + if (!definition) throw new Error(`Unknown PCI DSS control: ${controlId}`); + const state: ControlState = { status, evidence, testedAt: new Date().toISOString() }; + controlState.set(controlId, state); + return { ...definition, ...state }; +} + +export function getControlStatus(controlId: string): PciDssControlAssessment | null { + const definition = PCI_DSS_REQUIREMENTS.flatMap((r) => r.controls).find((c) => c.id === controlId); + if (!definition) return null; + const state = controlState.get(controlId) ?? { status: definition.defaultStatus }; + return { ...definition, ...state }; +} + +// ─── Assessment ─────────────────────────────────────────────────────────────── + +function deriveRequirementStatus(controls: PciDssControlAssessment[]): PciDssRequirementStatus { + const applicable = controls.filter((c) => c.status !== 'not_applicable'); + if (applicable.length === 0) return 'not_applicable'; + if (applicable.some((c) => c.status === 'not_in_place')) return 'non_compliant'; + if (applicable.some((c) => c.status === 'not_tested')) return 'in_place_with_deficiencies'; + return 'compliant'; +} + +function compliancePercent(controls: PciDssControlAssessment[]): number { + const applicable = controls.filter((c) => c.status !== 'not_applicable'); + if (applicable.length === 0) return 100; + const inPlace = applicable.filter((c) => c.status === 'in_place').length; + return Number(((inPlace / applicable.length) * 100).toFixed(2)); +} + +function priorityRank(priority: PciDssPriority): number { + return priority === 'high' ? 0 : priority === 'medium' ? 1 : 2; +} + +function sortGaps(gaps: PciDssGap[]): PciDssGap[] { + return [...gaps].sort( + (a, b) => + priorityRank(a.priority) - priorityRank(b.priority) || + a.requirementId - b.requirementId || + a.controlId.localeCompare(b.controlId), + ); +} + +export function assessPciDss(): PciDssRequirementAssessment[] { + return PCI_DSS_REQUIREMENTS.map((requirement) => { + const controls: PciDssControlAssessment[] = requirement.controls.map((item) => ({ + ...item, + status: controlState.get(item.id)?.status ?? item.defaultStatus, + evidence: controlState.get(item.id)?.evidence, + testedAt: controlState.get(item.id)?.testedAt, + })); + + const gaps: PciDssGap[] = controls + .filter((c) => c.status === 'not_in_place' || c.status === 'not_tested') + .map((c) => ({ + controlId: c.id, + controlTitle: c.title, + requirementId: requirement.id, + requirementTitle: requirement.title, + status: c.status as Exclude, + priority: c.priority, + reference: c.reference, + remediation: c.remediation, + })) + .sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.controlId.localeCompare(b.controlId)); + + return { + id: requirement.id, + title: requirement.title, + category: requirement.category, + weight: requirement.weight, + status: deriveRequirementStatus(controls), + compliancePercent: compliancePercent(controls), + controls, + gaps, + }; + }); +} + +export function buildPciDssDashboard(options: { assessedBy?: string } = {}): PciDssDashboard { + const requirements = assessPciDss(); + + const requirementStatusCounts: Record = { + compliant: 0, + in_place_with_deficiencies: 0, + non_compliant: 0, + not_applicable: 0, + }; + const controlStatusCounts: Record = { + in_place: 0, + not_in_place: 0, + not_tested: 0, + not_applicable: 0, + }; + + let weightedScore = 0; + let weightTotal = 0; + let testableControls = 0; + let testedControls = 0; + + for (const requirement of requirements) { + requirementStatusCounts[requirement.status] += 1; + + const applicable = requirement.controls.filter((c) => c.status !== 'not_applicable'); + if (applicable.length > 0) { + weightedScore += requirement.compliancePercent * requirement.weight; + weightTotal += requirement.weight; + } + + for (const item of requirement.controls) { + controlStatusCounts[item.status] += 1; + if (item.status !== 'not_applicable') { + testableControls += 1; + if (item.status !== 'not_tested') testedControls += 1; + } + } + } + + const overallScore = weightTotal > 0 ? Number((weightedScore / weightTotal).toFixed(2)) : 100; + const overallStatus: PciDssRequirementStatus = requirementStatusCounts.non_compliant > 0 + ? 'non_compliant' + : requirementStatusCounts.in_place_with_deficiencies > 0 + ? 'in_place_with_deficiencies' + : 'compliant'; + + const gaps = sortGaps(requirements.flatMap((r) => r.gaps)); + + return { + version: '4.0', + generatedAt: new Date().toISOString(), + overallScore, + overallStatus, + assessedBy: options.assessedBy, + requirementStatusCounts, + controlStatusCounts, + evidenceCoverage: testableControls > 0 ? Number(((testedControls / testableControls) * 100).toFixed(2)) : 100, + requirements, + gaps, + }; +} + +export function getPciDssGaps(priority?: PciDssPriority): PciDssGap[] { + const gaps = sortGaps(assessPciDss().flatMap((r) => r.gaps)); + return priority ? gaps.filter((gap) => gap.priority === priority) : gaps; +} + +export function exportPciDssEvidenceCsv(): string { + const header = 'requirement_id,requirement,control_id,control,status,priority,reference,evidence,tested_at'; + const rows: string[] = []; + for (const requirement of assessPciDss()) { + for (const item of requirement.controls) { + rows.push( + [ + requirement.id, + requirement.title, + item.id, + item.title, + item.status, + item.priority, + item.reference, + `"${(item.evidence ?? '').replace(/"/g, '""')}"`, + item.testedAt ?? '', + ].join(','), + ); + } + } + return [header, ...rows].join('\n'); +} diff --git a/backend/src/services/sanctions/__tests__/ofac-screening.test.ts b/backend/src/services/sanctions/__tests__/ofac-screening.test.ts new file mode 100644 index 00000000..17606743 --- /dev/null +++ b/backend/src/services/sanctions/__tests__/ofac-screening.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_MATCH_THRESHOLD, + SAMPLE_SANCTIONS_ENTRIES, + clearScreeningHistory, + editSimilarity, + getScreeningHistory, + getSanctionsEntity, + getSanctionsListSize, + levenshtein, + listSanctionsEntities, + loadSanctionsEntries, + nameSimilarity, + normalizeName, + removeSanctionsEntity, + screenEntity, + screenTransaction, + tokenSimilarity, + upsertSanctionsEntity, +} from '../ofac-screening.js'; + +beforeEach(() => { + loadSanctionsEntries(SAMPLE_SANCTIONS_ENTRIES, { replace: true }); + clearScreeningHistory(); +}); + +describe('sanctions name normalisation and similarity (#923)', () => { + it('strips diacritics, punctuation and noise tokens', () => { + expect(normalizeName('Ivanov, Sergei Petrovich')).toBe('ivanov sergei petrovich'); + expect(normalizeName('Acme Shipping Company Ltd.')).toBe('acme shipping'); + expect(normalizeName('José Öztürk')).toBe('jose ozturk'); + }); + + it('computes Levenshtein distance and edit similarity', () => { + expect(levenshtein('kitten', 'sitting')).toBe(3); + expect(levenshtein('', 'abc')).toBe(3); + expect(editSimilarity('ivanov', 'ivanov')).toBe(1); + expect(editSimilarity('ivanov', 'ivanof')).toBeCloseTo(5 / 6, 5); + }); + + it('matches reordered tokens via token-set similarity', () => { + expect(tokenSimilarity('sergei ivanov', 'ivanov sergei petrovich')).toBe(1); + expect(nameSimilarity('Sergei Ivanov', 'Ivanov, Sergei Petrovich')).toBe(1); + }); +}); + +describe('sanctions screening decisions (#923)', () => { + it('returns an exact match for a listed name', () => { + const result = screenEntity({ name: 'Ivanov, Sergei Petrovich' }); + expect(result.status).toBe('match'); + expect(result.matches[0].entity.id).toBe('sdn-sample-0001'); + expect(result.matches[0].reasons).toContain('exact_name_match'); + }); + + it('matches on a reordered alias', () => { + const result = screenEntity({ name: 'Sergey Ivanov' }); + expect(result.status).toBe('match'); + expect(result.matches[0].entity.id).toBe('sdn-sample-0001'); + }); + + it('treats a matching national id as decisive even when the name differs', () => { + const result = screenEntity({ name: 'Completely Unrelated', nationalId: 'RU-680412-001' }); + expect(result.status).toBe('match'); + expect(result.matches[0].score).toBe(1); + expect(result.matches[0].reasons).toContain('national_id_match'); + }); + + it('skips the entity-type penalty when the type is omitted', () => { + const typed = screenEntity({ name: 'Acme Shipping Company Ltd', entityType: 'vessel' }); + const untyped = screenEntity({ name: 'Acme Shipping Company Ltd' }); + expect(typed.matches[0].score).toBeLessThan(untyped.matches[0].score); + expect(typed.matches[0].reasons).toContain('entity_type_mismatch'); + }); + + it('returns clear for unrelated names', () => { + const result = screenEntity({ name: 'Alice Wonderland' }); + expect(result.status).toBe('clear'); + expect(result.matches).toHaveLength(0); + }); + + it('honours a custom potential threshold and list filter', () => { + const partial = screenEntity({ name: 'Ivanov Sergei Xyz' }); + expect(partial.status).toBe('potential_match'); + + const strict = screenEntity({ name: 'Ivanov Sergei Xyz' }, { potentialThreshold: 0.9 }); + expect(strict.status).toBe('clear'); + + const limited = screenEntity({ name: 'Ivanov, Sergei Petrovich' }, { lists: ['EU'] }); + expect(limited.status).toBe('clear'); + expect(limited.considered).toBe(0); + }); + + it('caps the number of returned matches', () => { + const result = screenEntity({ name: 'Sea Breeze' }, { maxResults: 1 }); + expect(result.matches.length).toBeLessThanOrEqual(1); + }); + + it('reports the default thresholds', () => { + const result = screenEntity({ name: 'Nobody Here' }); + expect(result.threshold).toBe(DEFAULT_MATCH_THRESHOLD); + expect(result.potentialThreshold).toBeLessThan(result.threshold); + }); +}); + +describe('sanctions transaction screening (#923)', () => { + it('escalates when the payee matches', () => { + const result = screenTransaction({ + transactionId: 'tx_1', + payer: { name: 'Alice Wonderland' }, + payee: { name: 'Ivanov, Sergei Petrovich' }, + }); + expect(result.status).toBe('match'); + expect(result.payee?.status).toBe('match'); + expect(result.payer?.status).toBe('clear'); + }); + + it('stays clear when both parties are clean', () => { + const result = screenTransaction({ + payer: { name: 'Alice Wonderland' }, + payee: { name: 'Bob Builder' }, + }); + expect(result.status).toBe('clear'); + }); +}); + +describe('sanctions watchlist management (#923)', () => { + it('adds, reads and removes entries', () => { + const sizeBefore = getSanctionsListSize(); + const added = upsertSanctionsEntity({ + id: 'custom-1', + list: 'EU', + name: 'Test Entity', + entityType: 'entity', + }); + expect(added.id).toBe('custom-1'); + expect(getSanctionsListSize()).toBe(sizeBefore + 1); + expect(getSanctionsEntity('custom-1')?.name).toBe('Test Entity'); + + expect(removeSanctionsEntity('custom-1')).toBe(true); + expect(removeSanctionsEntity('custom-1')).toBe(false); + expect(getSanctionsEntity('custom-1')).toBeNull(); + }); + + it('filters entries by list, type and country', () => { + expect(listSanctionsEntities({ list: 'OFAC_CONSOLIDATED' }).every((e) => e.list === 'OFAC_CONSOLIDATED')).toBe(true); + expect(listSanctionsEntities({ entityType: 'vessel' })).toHaveLength(1); + expect(listSanctionsEntities({ country: 'ru' }).length).toBeGreaterThan(0); + }); + + it('replaces the whole watchlist on demand', () => { + const loaded = loadSanctionsEntries( + [{ id: 'only', list: 'UN', name: 'Solo Entity', entityType: 'entity' }], + { replace: true }, + ); + expect(loaded).toBe(1); + expect(getSanctionsListSize()).toBe(1); + }); +}); + +describe('sanctions screening history (#923)', () => { + it('records each screening and supports filters', () => { + screenEntity({ name: 'Ivanov, Sergei Petrovich' }); + screenEntity({ name: 'Alice Wonderland' }); + + const all = getScreeningHistory(); + expect(all).toHaveLength(2); + expect(getScreeningHistory({ status: 'match' })).toHaveLength(1); + expect(getScreeningHistory({ status: 'clear' })).toHaveLength(1); + expect(getScreeningHistory({ since: new Date(Date.now() + 60_000).toISOString() })).toHaveLength(0); + }); +}); diff --git a/backend/src/services/sanctions/ofac-screening.ts b/backend/src/services/sanctions/ofac-screening.ts new file mode 100644 index 00000000..15d4c2a7 --- /dev/null +++ b/backend/src/services/sanctions/ofac-screening.ts @@ -0,0 +1,465 @@ +/** + * Sanctions Screening against OFAC lists — Issue #923 + * + * Screens customers, counterparties and transactions against a watchlist that + * models the OFAC Specially Designated Nationals (SDN) and consolidated lists. + * Matching is fuzzy by design: names are normalised, compared with token and + * edit-distance similarity, then boosted by strong identifiers (date of birth, + * national id, country) so that transliteration and ordering differences do not + * hide a real hit. + * + * The service ships with illustrative sample entries. Bulk-load real list data + * with `loadSanctionsEntries()` in a scheduled refresh job. + */ + +import { randomUUID } from 'node:crypto'; + +export type SanctionsListName = 'OFAC_SDN' | 'OFAC_CONSOLIDATED' | 'EU' | 'UN' | 'UK_OFSI'; +export type SanctionsEntityType = 'individual' | 'entity' | 'vessel' | 'aircraft'; +export type SanctionsMatchStatus = 'clear' | 'potential_match' | 'match'; + +export interface SanctionsEntity { + id: string; + list: SanctionsListName; + name: string; + aliases?: string[]; + entityType: SanctionsEntityType; + programs?: string[]; + countries?: string[]; + dateOfBirth?: string; // YYYY-MM-DD + nationalIds?: string[]; + remarks?: string; +} + +export interface SanctionsQuery { + name: string; + aliases?: string[]; + entityType?: SanctionsEntityType; + countries?: string[]; + dateOfBirth?: string; + nationalId?: string; +} + +export interface SanctionsMatch { + entity: SanctionsEntity; + score: number; + matchedName: string; + reasons: string[]; +} + +export interface SanctionsScreeningResult { + query: SanctionsQuery; + status: SanctionsMatchStatus; + matches: SanctionsMatch[]; + screenedAt: string; + threshold: number; + potentialThreshold: number; + lists: SanctionsListName[]; + considered: number; +} + +export interface ScreeningOptions { + /** Minimum score for a definitive match. Default 0.85. */ + threshold?: number; + /** Minimum score for a potential match. Default 0.65. */ + potentialThreshold?: number; + /** Restrict screening to these lists. Defaults to every loaded list. */ + lists?: SanctionsListName[]; + /** Maximum number of matches to return (highest score first). Default 10. */ + maxResults?: number; +} + +export interface SanctionsScreeningRecord { + id: string; + queryName: string; + status: SanctionsMatchStatus; + topScore: number; + matchIds: string[]; + screenedAt: string; +} + +export const DEFAULT_MATCH_THRESHOLD = 0.85; +export const DEFAULT_POTENTIAL_THRESHOLD = 0.65; +const MAX_HISTORY = 500; + +// ─── Name normalisation ─────────────────────────────────────────────────────── + +const NOISE_TOKENS = new Set([ + 'mr', 'mrs', 'ms', 'miss', 'dr', 'prof', 'sir', 'lord', + 'ltd', 'limited', 'llc', 'llp', 'inc', 'incorporated', 'corp', 'corporation', + 'co', 'company', 'gmbh', 'sa', 'ag', 'plc', 'bv', 'nv', 'pjsc', 'ojsc', 'jsc', +]); + +/** + * Normalise a name for comparison: strip diacritics/punctuation, collapse + * whitespace, lowercase and drop corporate/honorific noise tokens. + */ +export function normalizeName(name: string): string { + const cleaned = name + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9\s]/g, ' ') + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .filter((token) => !NOISE_TOKENS.has(token)); + return cleaned.join(' ').trim(); +} + +function tokenSet(normalizedName: string): Set { + return new Set(normalizedName.split(' ').filter(Boolean)); +} + +// ─── Similarity ─────────────────────────────────────────────────────────────── + +/** Iterative Levenshtein distance with O(min(m,n)) memory. */ +export function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a]; + let previous = Array.from({ length: shorter.length + 1 }, (_, i) => i); + + for (let i = 1; i <= longer.length; i += 1) { + const current = [i]; + for (let j = 1; j <= shorter.length; j += 1) { + const cost = longer[i - 1] === shorter[j - 1] ? 0 : 1; + current[j] = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + cost, + ); + } + previous = current; + } + + return previous[shorter.length]; +} + +/** Edit similarity in [0, 1]. */ +export function editSimilarity(a: string, b: string): number { + if (!a && !b) return 1; + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + return 1 - levenshtein(a, b) / maxLen; +} + +/** Token-set (order-insensitive) similarity in [0, 1]. */ +export function tokenSimilarity(a: string, b: string): number { + const setA = tokenSet(a); + const setB = tokenSet(b); + if (setA.size === 0 && setB.size === 0) return 1; + let intersection = 0; + for (const token of setA) { + if (setB.has(token)) intersection += 1; + } + const union = setA.size + setB.size - intersection; + const jaccard = union === 0 ? 0 : intersection / union; + const containment = Math.min(setA.size, setB.size) === 0 + ? 0 + : intersection / Math.min(setA.size, setB.size); + return Math.max(jaccard, containment); +} + +/** Combined name similarity used by the matcher. */ +export function nameSimilarity(a: string, b: string): number { + const na = normalizeName(a); + const nb = normalizeName(b); + if (!na || !nb) return 0; + return Math.max(editSimilarity(na, nb), tokenSimilarity(na, nb)); +} + +// ─── Watchlist store ────────────────────────────────────────────────────────── + +const watchlist = new Map(); +const screeningHistory: SanctionsScreeningRecord[] = []; + +/** + * Illustrative sample entries used to make the screening engine usable out of + * the box. These are fictional fixtures, not the actual OFAC list. + */ +export const SAMPLE_SANCTIONS_ENTRIES: SanctionsEntity[] = [ + { + id: 'sdn-sample-0001', + list: 'OFAC_SDN', + name: 'Ivanov, Sergei Petrovich', + aliases: ['Sergey Ivanov', 'S. P. Ivanov'], + entityType: 'individual', + programs: ['RUSSIA-EO14024'], + countries: ['RU'], + dateOfBirth: '1968-04-12', + nationalIds: ['RU-680412-001'], + }, + { + id: 'sdn-sample-0002', + list: 'OFAC_SDN', + name: 'Petrov, Dmitry Alexandrovich', + aliases: ['Dmitri Petrov'], + entityType: 'individual', + programs: ['RUSSIA-EO14024'], + countries: ['RU'], + dateOfBirth: '1974-11-03', + }, + { + id: 'sdn-sample-0003', + list: 'OFAC_SDN', + name: 'Acme Shipping Company Ltd', + aliases: ['Acme Shipping Co'], + entityType: 'entity', + programs: ['IRAN-EO13902'], + countries: ['IR'], + }, + { + id: 'sdn-sample-0004', + list: 'OFAC_CONSOLIDATED', + name: 'Global Trading and Logistics LLC', + aliases: ['Global Trading LLC', 'GTL'], + entityType: 'entity', + programs: ['SYRIA-EO13894'], + countries: ['SY'], + }, + { + id: 'sdn-sample-0005', + list: 'OFAC_SDN', + name: 'Sea Breeze', + aliases: ['MV Sea Breeze', 'M/V Sea Breeze'], + entityType: 'vessel', + programs: ['NORTH-KOREA-EO13810'], + countries: ['KP'], + }, +]; + +export function loadSanctionsEntries(entries: SanctionsEntity[], options: { replace?: boolean } = {}): number { + if (options.replace) watchlist.clear(); + let loaded = 0; + for (const entry of entries) { + watchlist.set(entry.id, { ...entry }); + loaded += 1; + } + return loaded; +} + +export function upsertSanctionsEntity(entry: Omit & { id?: string }): SanctionsEntity { + const stored: SanctionsEntity = { ...entry, id: entry.id ?? `sdn_${randomUUID()}` }; + watchlist.set(stored.id, stored); + return stored; +} + +export function removeSanctionsEntity(id: string): boolean { + return watchlist.delete(id); +} + +export function getSanctionsEntity(id: string): SanctionsEntity | null { + return watchlist.get(id) ?? null; +} + +export function listSanctionsEntities(filter?: { + list?: SanctionsListName; + entityType?: SanctionsEntityType; + country?: string; +}): SanctionsEntity[] { + let list = Array.from(watchlist.values()); + if (filter?.list) list = list.filter((e) => e.list === filter.list); + if (filter?.entityType) list = list.filter((e) => e.entityType === filter.entityType); + if (filter?.country) { + const country = filter.country.toUpperCase(); + list = list.filter((e) => (e.countries ?? []).map((c) => c.toUpperCase()).includes(country)); + } + return list.sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getSanctionsListSize(): number { + return watchlist.size; +} + +// ─── Screening ──────────────────────────────────────────────────────────────── + +function scoreEntity(query: SanctionsQuery, entity: SanctionsEntity): SanctionsMatch | null { + const reasons: string[] = []; + const candidateNames = [entity.name, ...(entity.aliases ?? [])]; + + let bestName = entity.name; + let bestNameScore = 0; + for (const candidate of candidateNames) { + const score = nameSimilarity(query.name, candidate); + if (score > bestNameScore) { + bestNameScore = score; + bestName = candidate; + } + } + + // Also consider any query aliases against the entity names. + for (const alias of query.aliases ?? []) { + for (const candidate of candidateNames) { + const score = nameSimilarity(alias, candidate); + if (score > bestNameScore) { + bestNameScore = score; + bestName = candidate; + reasons.push(`alias_match:${alias}`); + } + } + } + + if (bestNameScore === 0 && !(query.nationalId && entity.nationalIds?.includes(query.nationalId))) { + return null; + } + + let score = bestNameScore; + if (bestNameScore >= 0.999) reasons.push('exact_name_match'); + else if (bestNameScore >= DEFAULT_POTENTIAL_THRESHOLD) reasons.push('fuzzy_name_match'); + + // Strong identifier: national id exact match is decisive. + if (query.nationalId && entity.nationalIds?.includes(query.nationalId)) { + score = 1; + reasons.push('national_id_match'); + } + + // Date of birth corroboration. + if (query.dateOfBirth && entity.dateOfBirth) { + if (query.dateOfBirth === entity.dateOfBirth) { + score = Math.min(1, score + 0.1); + reasons.push('dob_match'); + } else { + score = Math.max(0, score - 0.05); + reasons.push('dob_mismatch'); + } + } + + // Country corroboration. + if (query.countries && query.countries.length > 0 && entity.countries) { + const queryCountries = query.countries.map((c) => c.toUpperCase()); + const entityCountries = entity.countries.map((c) => c.toUpperCase()); + if (queryCountries.some((c) => entityCountries.includes(c))) { + score = Math.min(1, score + 0.05); + reasons.push('country_match'); + } + } + + // Entity-type mismatch slightly weakens the signal. + if (query.entityType && entity.entityType !== query.entityType) { + score = Math.max(0, score - 0.1); + reasons.push('entity_type_mismatch'); + } + + return { + entity, + score: Number(score.toFixed(4)), + matchedName: bestName, + reasons, + }; +} + +export function screenEntity( + query: SanctionsQuery, + options: ScreeningOptions = {}, +): SanctionsScreeningResult { + const threshold = options.threshold ?? DEFAULT_MATCH_THRESHOLD; + const potentialThreshold = options.potentialThreshold ?? DEFAULT_POTENTIAL_THRESHOLD; + const maxResults = options.maxResults ?? 10; + + let candidates = Array.from(watchlist.values()); + if (options.lists && options.lists.length > 0) { + candidates = candidates.filter((entity) => options.lists?.includes(entity.list)); + } + + const matches = candidates + .map((entity) => scoreEntity(query, entity)) + .filter((match): match is SanctionsMatch => match !== null && match.score >= potentialThreshold) + .sort((a, b) => b.score - a.score) + .slice(0, maxResults); + + let status: SanctionsMatchStatus = 'clear'; + if (matches.some((match) => match.score >= threshold)) status = 'match'; + else if (matches.length > 0) status = 'potential_match'; + + const result: SanctionsScreeningResult = { + query, + status, + matches, + screenedAt: new Date().toISOString(), + threshold, + potentialThreshold, + lists: options.lists ?? Array.from(new Set(candidates.map((c) => c.list))), + considered: candidates.length, + }; + + recordScreening(result); + return result; +} + +export interface TransactionScreeningInput { + transactionId?: string; + payer?: SanctionsQuery; + payee?: SanctionsQuery; +} + +export interface TransactionScreeningResult { + transactionId?: string; + status: SanctionsMatchStatus; + payer?: SanctionsScreeningResult; + payee?: SanctionsScreeningResult; + screenedAt: string; +} + +export function screenTransaction( + input: TransactionScreeningInput, + options?: ScreeningOptions, +): TransactionScreeningResult { + const payer = input.payer ? screenEntity(input.payer, options) : undefined; + const payee = input.payee ? screenEntity(input.payee, options) : undefined; + + const statuses = [payer?.status, payee?.status]; + let status: SanctionsMatchStatus = 'clear'; + if (statuses.includes('match')) status = 'match'; + else if (statuses.includes('potential_match')) status = 'potential_match'; + + return { + transactionId: input.transactionId, + status, + payer, + payee, + screenedAt: new Date().toISOString(), + }; +} + +// ─── Screening history ──────────────────────────────────────────────────────── + +function recordScreening(result: SanctionsScreeningResult): void { + screeningHistory.push({ + id: `screen_${randomUUID()}`, + queryName: result.query.name, + status: result.status, + topScore: result.matches[0]?.score ?? 0, + matchIds: result.matches.map((m) => m.entity.id), + screenedAt: result.screenedAt, + }); + if (screeningHistory.length > MAX_HISTORY) screeningHistory.shift(); +} + +export function getScreeningHistory(filter?: { + status?: SanctionsMatchStatus; + since?: string; +}): SanctionsScreeningRecord[] { + let list = [...screeningHistory]; + if (filter?.status) list = list.filter((record) => record.status === filter.status); + if (filter?.since) { + const sinceMs = Date.parse(filter.since); + if (!Number.isNaN(sinceMs)) { + list = list.filter((record) => Date.parse(record.screenedAt) >= sinceMs); + } + } + return list.sort((a, b) => Date.parse(b.screenedAt) - Date.parse(a.screenedAt)); +} + +export function clearScreeningHistory(): void { + screeningHistory.length = 0; +} + +/** Seed the watchlist with the bundled sample entries (idempotent). */ +export function seedSampleWatchlist(): number { + return loadSanctionsEntries(SAMPLE_SANCTIONS_ENTRIES); +} + +// Seed at module load so the API is usable without an explicit bootstrap step. +seedSampleWatchlist(); diff --git a/backend/src/services/settlement/__tests__/nano-settle-rail.test.ts b/backend/src/services/settlement/__tests__/nano-settle-rail.test.ts new file mode 100644 index 00000000..d146615b --- /dev/null +++ b/backend/src/services/settlement/__tests__/nano-settle-rail.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { + NANO_CONFIG, + NanoSettleRail, + createNanoSettleRail, + isValidNanoAddress, + rawToXno, + resolveSettlementRail, + xnoToRaw, +} from '../nano-settle-rail.js'; + +const PAYER = 'nano_3111111111111111111111111111111111111111111111111111hifc8npp'; +const PAYEE = 'nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3'; +const OTHER = 'nano_1t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3'; +const NOW = 1_700_000_000_000; + +function buildRail(): NanoSettleRail { + return createNanoSettleRail({ now: () => NOW }); +} + +describe('Nano address and amount helpers (#1004)', () => { + it('validates nano_ and xrb_ addresses', () => { + expect(isValidNanoAddress(PAYER)).toBe(true); + expect(isValidNanoAddress(PAYEE)).toBe(true); + expect(isValidNanoAddress(`xrb_${PAYEE.slice(5)}`)).toBe(true); + expect(isValidNanoAddress('nano_short')).toBe(false); + expect(isValidNanoAddress(PAYER.slice(0, -1))).toBe(false); + expect(isValidNanoAddress('btc_1111111111111111111111111111111111111111111111111111hifc8npp')).toBe(false); + }); + + it('converts XNO to raw and back', () => { + expect(xnoToRaw(1)).toBe(NANO_CONFIG.rawPerXno); + expect(xnoToRaw(0)).toBe(0n); + expect(rawToXno(NANO_CONFIG.rawPerXno)).toBe(1); + expect(rawToXno(xnoToRaw(12.345))).toBeCloseTo(12.345, 10); + }); + + it('rejects invalid amounts', () => { + expect(() => xnoToRaw(-1)).toThrow(/invalid/i); + expect(() => xnoToRaw(Number.NaN)).toThrow(/invalid/i); + }); + + it('resolves the settlement rail per asset', () => { + expect(resolveSettlementRail('XNO')).toBe('nano'); + expect(resolveSettlementRail('xno')).toBe('nano'); + expect(resolveSettlementRail('USDC')).toBe('stellar'); + }); +}); + +describe('Nano escrow hold (#1004)', () => { + it('holds funds as one feeless send', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + + const escrow = rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, escrowId: 'esc_1' }); + + expect(escrow.status).toBe('held'); + expect(escrow.amountXno).toBe(4); + expect(escrow.holdBlock.type).toBe('send'); + expect(escrow.holdBlock.feeless).toBe(true); + expect(escrow.holdBlock.link).toBe(escrow.escrowAccount); + expect(escrow.holdBlock.amountXno).toBe(4); + expect(rail.getBalanceXno(PAYER)).toBe(6); + expect(rail.getBalanceXno(escrow.escrowAccount)).toBe(4); + }); + + it('rejects a hold when the payer has insufficient balance', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 1); + expect(() => rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 5 })).toThrow(/insufficient/i); + }); + + it('rejects invalid addresses and amounts', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + expect(() => rail.holdEscrow({ payer: 'not-an-address', payee: PAYEE, amountXno: 1 })).toThrow(/invalid payer/i); + expect(() => rail.holdEscrow({ payer: PAYER, payee: 'nope', amountXno: 1 })).toThrow(/invalid payee/i); + expect(() => rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 0 })).toThrow(/greater than zero/i); + }); + + it('replays an idempotency key instead of holding twice', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + + const first = rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, idempotencyKey: 'idem-1' }); + const second = rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, idempotencyKey: 'idem-1' }); + + expect(second.id).toBe(first.id); + expect(second.holdBlock.hash).toBe(first.holdBlock.hash); + expect(rail.getBalanceXno(PAYER)).toBe(6); + expect(rail.listEscrows()).toHaveLength(1); + }); + + it('rejects an idempotency key reused with different details', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, idempotencyKey: 'idem-1' }); + + expect(() => + rail.holdEscrow({ payer: PAYER, payee: OTHER, amountXno: 4, idempotencyKey: 'idem-1' }), + ).toThrow(/IDEMPOTENCY_KEY_REUSE_MISMATCH/); + }); +}); + +describe('Nano escrow release and refund (#1004)', () => { + it('releases funds in one feeless block that doubles as the receipt', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + const escrow = rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, escrowId: 'esc_1' }); + + const released = rail.releaseEscrow('esc_1', { approvedBy: 'agent-7' }); + + expect(released.status).toBe('released'); + expect(released.releaseBlock?.feeless).toBe(true); + expect(released.releaseBlock?.link).toBe(PAYEE); + expect(released.approvedBy).toBe('agent-7'); + expect(rail.getBalanceXno(PAYEE)).toBe(4); + expect(rail.getBalanceXno(escrow.escrowAccount)).toBe(0); + + const receipt = rail.verifyEscrowReceipt('esc_1'); + expect(receipt.verified).toBe(true); + expect(receipt.feeless).toBe(true); + expect(receipt.to).toBe(PAYEE); + expect(receipt.amountXno).toBe(4); + expect(receipt.blockHash).toBe(released.releaseBlock?.hash); + }); + + it('is idempotent when releasing an already-released escrow', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, escrowId: 'esc_1' }); + + const first = rail.releaseEscrow('esc_1'); + const second = rail.releaseEscrow('esc_1'); + + expect(second.releaseBlock?.hash).toBe(first.releaseBlock?.hash); + expect(rail.getBalanceXno(PAYEE)).toBe(4); + }); + + it('refunds funds back to the payer', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + const escrow = rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, escrowId: 'esc_1' }); + + const refunded = rail.refundEscrow('esc_1', { reason: 'milestone rejected' }); + + expect(refunded.status).toBe('refunded'); + expect(refunded.refundBlock?.link).toBe(PAYER); + expect(rail.getBalanceXno(PAYER)).toBe(10); + expect(rail.getBalanceXno(escrow.escrowAccount)).toBe(0); + expect((refunded.metadata as Record)?.refundReason).toBe('milestone rejected'); + }); + + it('refuses to release or refund escrows in the wrong state', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, escrowId: 'esc_1' }); + rail.refundEscrow('esc_1'); + + expect(() => rail.releaseEscrow('esc_1')).toThrow(/cannot be released/i); + expect(() => rail.releaseEscrow('missing')).toThrow(/not found/i); + }); + + it('does not verify a receipt for an unreleased escrow', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 10); + rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 4, escrowId: 'esc_1' }); + + const receipt = rail.verifyEscrowReceipt('esc_1'); + expect(receipt.verified).toBe(false); + expect(receipt.blockHash).toBeUndefined(); + }); +}); + +describe('Nano rail bookkeeping (#1004)', () => { + it('tracks escrows, metrics and blocks', () => { + const rail = buildRail(); + rail.fundAccount(PAYER, 100); + + const held = rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 1, escrowId: 'held' }); + rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 2, escrowId: 'released' }); + rail.releaseEscrow('released'); + rail.holdEscrow({ payer: PAYER, payee: PAYEE, amountXno: 3, escrowId: 'refunded' }); + rail.refundEscrow('refunded'); + rail.holdEscrow({ payer: PAYER, payee: OTHER, amountXno: 4, escrowId: 'different-payee' }); + + const metrics = rail.getMetrics(); + expect(metrics.escrows).toBe(4); + expect(metrics.held).toBe(2); + expect(metrics.released).toBe(1); + expect(metrics.refunded).toBe(1); + expect(metrics.feesCollectedRaw).toBe('0'); + expect(metrics.feesCollectedXno).toBe(0); + + expect(rail.listEscrows({ status: 'released' })).toHaveLength(1); + expect(rail.listEscrows({ payee: OTHER })).toHaveLength(1); + + expect(rail.getBlock(held.holdBlock.hash)?.hash).toBe(held.holdBlock.hash); + expect(rail.getBlock('deadbeef')).toBeNull(); + expect(rail.getEscrow('missing')).toBeNull(); + }); +}); diff --git a/backend/src/services/settlement/nano-settle-rail.ts b/backend/src/services/settlement/nano-settle-rail.ts new file mode 100644 index 00000000..83c52caa --- /dev/null +++ b/backend/src/services/settlement/nano-settle-rail.ts @@ -0,0 +1,475 @@ +/** + * Nano (XNO) Settle Rail — Issue #1004 + * + * Adds a feeless Nano settle rail beside the Stellar/Soroban escrow path. On + * Nano a "hold then release" is two feeless sends: no smart-contract execution, + * no gas, no submission fee, and the release block is final in roughly a second. + * Because the ledger itself is the public receipt, anyone can verify the release + * block (destination, amount, ordering) without trusting either party. + * + * This module models that two-block flow: + * + * hold: payer --(feeless send)--> escrow/agent account + * release: escrow --(feeless send)--> payee (one block = confirmation) + * + * Block hashes and signatures are deterministic simulations so the rail can be + * unit tested offline. Wire `NanoSettleRail` to a Nano RPC node to broadcast the + * real `block_create` / `process` calls. + */ + +import { createHash, createHmac, randomUUID } from 'node:crypto'; + +export type NanoBlockType = 'send' | 'receive'; +export type NanoEscrowStatus = 'held' | 'released' | 'refunded' | 'expired'; + +export interface NanoBlock { + hash: string; + type: NanoBlockType; + /** Account that owns/creates the block. */ + account: string; + /** Previous block hash, or 64 zeros for an account's first block. */ + previous: string; + representative: string; + /** Resulting account balance in raw, as a decimal string. */ + balance: string; + /** For a send: destination address. For a receive: source block hash. */ + link: string; + signature: string; + amountRaw: string; + amountXno: number; + timestamp: string; + /** Nano transfers carry no fee. */ + feeless: true; + /** Time to confirmation, in milliseconds (one confirmation ≈ 1s). */ + finalityMs: number; + confirmed: true; +} + +export interface NanoEscrow { + id: string; + payer: string; + payee: string; + escrowAccount: string; + representative: string; + amountXno: number; + amountRaw: string; + status: NanoEscrowStatus; + holdBlock: NanoBlock; + releaseBlock?: NanoBlock; + refundBlock?: NanoBlock; + idempotencyKey?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; + releasedAt?: string; + refundedAt?: string; + approvedBy?: string; +} + +export interface HoldEscrowInput { + payer: string; + payee: string; + amountXno: number; + representative?: string; + escrowId?: string; + idempotencyKey?: string; + metadata?: Record; +} + +export interface NanoRailConfig { + /** Default representative used when a caller does not supply one. */ + representative?: string; + /** Account (or agent) that custodies held funds. */ + escrowAccount?: string; + /** Simulated confirmation time in milliseconds. */ + finalityMs?: number; + /** Injectable clock for deterministic tests. */ + now?: () => number; +} + +export interface NanoReceiveVerification { + escrowId: string; + status: NanoEscrowStatus; + blockHash?: string; + to?: string; + amountXno?: number; + feeless: true; + finalityMs: number; + /** True once the release send is included in the ledger. */ + verified: boolean; + explorerHint: string; +} + +export interface NanoRailMetrics { + escrows: number; + held: number; + released: number; + refunded: number; + feesCollectedRaw: '0'; + feesCollectedXno: 0; +} + +export const NANO_CONFIG = { + ticker: 'XNO', + decimals: 30, + rawPerXno: 10n ** 30n, + /** Nano's reference representative (burn address representative). */ + defaultRepresentative: 'nano_1111111111111111111111111111111111111111111111111111hifc8npp', + /** Default escrow/agent custody account (burn-address style placeholder). */ + defaultEscrowAccount: 'nano_1111111111111111111111111111111111111111111111111111hifc8npp', + finalityMs: 1_000, + zeroPrevious: '0'.repeat(64), +} as const; + +export const SUPPORTED_SETTLEMENT_RAILS = ['stellar', 'nano'] as const; +export type SettlementRailId = (typeof SUPPORTED_SETTLEMENT_RAILS)[number]; + +const NANO_ALPHABET = '13456789abcdefghijkmnopqrstuwxyz'; + +/** Nano addresses are `nano_`/`xrb_` followed by 60 base32 characters. */ +export function isValidNanoAddress(address: string): boolean { + if (typeof address !== 'string') return false; + const prefix = address.startsWith('nano_') ? 'nano_' : address.startsWith('xrb_') ? 'xrb_' : null; + if (!prefix) return false; + const body = address.slice(prefix.length); + if (body.length !== 60) return false; + return [...body].every((char) => NANO_ALPHABET.includes(char)); +} + +/** Convert a decimal XNO amount to raw (10^-30 XNO) units. */ +export function xnoToRaw(amountXno: number): bigint { + if (!Number.isFinite(amountXno) || amountXno < 0) { + throw new Error(`Invalid XNO amount: ${amountXno}`); + } + const fixed = amountXno.toFixed(NANO_CONFIG.decimals); + const [whole, fraction = ''] = fixed.split('.'); + const fractionPadded = fraction.padEnd(NANO_CONFIG.decimals, '0').slice(0, NANO_CONFIG.decimals); + return BigInt(whole) * NANO_CONFIG.rawPerXno + BigInt(fractionPadded); +} + +/** Convert raw units back to a decimal XNO number. */ +export function rawToXno(raw: bigint | string): number { + const value = typeof raw === 'bigint' ? raw : BigInt(raw); + const whole = value / NANO_CONFIG.rawPerXno; + const fraction = value % NANO_CONFIG.rawPerXno; + return Number(whole) + Number(fraction) / 1e30; +} + +function sha256(payload: string): string { + return createHash('sha256').update(payload).digest('hex'); +} + +/** + * Nano's settle rail: hold and release as two feeless blocks. + */ +export class NanoSettleRail { + readonly id: SettlementRailId = 'nano'; + + private readonly representative: string; + private readonly escrowAccount: string; + private readonly finalityMs: number; + private readonly now: () => number; + + private readonly escrows = new Map(); + private readonly idempotency = new Map(); + private readonly idempotencyFingerprints = new Map(); + private readonly accountBalances = new Map(); + private sequence = 0; + + constructor(config: NanoRailConfig = {}) { + this.representative = config.representative ?? NANO_CONFIG.defaultRepresentative; + this.escrowAccount = config.escrowAccount ?? NANO_CONFIG.defaultEscrowAccount; + this.finalityMs = config.finalityMs ?? NANO_CONFIG.finalityMs; + this.now = config.now ?? (() => Date.now()); + } + + /** Fund an account with simulated balance (test/faucet helper). */ + fundAccount(address: string, amountXno: number): void { + if (!isValidNanoAddress(address)) throw new Error(`Invalid Nano address: ${address}`); + const current = this.accountBalances.get(address) ?? 0n; + this.accountBalances.set(address, current + xnoToRaw(amountXno)); + } + + getBalanceRaw(address: string): bigint { + return this.accountBalances.get(address) ?? 0n; + } + + getBalanceXno(address: string): number { + return rawToXno(this.getBalanceRaw(address)); + } + + private timestamp(): string { + return new Date(this.now()).toISOString(); + } + + private createBlock(params: { + type: NanoBlockType; + account: string; + previous: string; + representative: string; + balance: bigint; + link: string; + amountRaw: bigint; + }): NanoBlock { + this.sequence += 1; + const timestamp = this.timestamp(); + const canonical = [ + params.type, + params.account, + params.previous, + params.representative, + params.balance.toString(), + params.link, + params.amountRaw.toString(), + String(this.sequence), + timestamp, + ].join('|'); + const hash = sha256(canonical); + const signature = createHmac('sha256', 'nano-settle-rail-simulated').update(hash).digest('hex'); + + return { + hash, + type: params.type, + account: params.account, + previous: params.previous, + representative: params.representative, + balance: params.balance.toString(), + link: params.link, + signature, + amountRaw: params.amountRaw.toString(), + amountXno: rawToXno(params.amountRaw), + timestamp, + feeless: true, + finalityMs: this.finalityMs, + confirmed: true, + }; + } + + /** + * Hold funds: one feeless send from the payer to the escrow/agent account. + */ + holdEscrow(input: HoldEscrowInput): NanoEscrow { + if (!isValidNanoAddress(input.payer)) throw new Error(`Invalid payer address: ${input.payer}`); + if (!isValidNanoAddress(input.payee)) throw new Error(`Invalid payee address: ${input.payee}`); + if (!Number.isFinite(input.amountXno) || input.amountXno <= 0) { + throw new Error('Escrow amount must be greater than zero'); + } + + if (input.idempotencyKey) { + const existingId = this.idempotency.get(input.idempotencyKey); + if (existingId) { + const fingerprint = this.fingerprint(input); + const previous = this.idempotencyFingerprints.get(input.idempotencyKey); + if (previous && previous !== fingerprint) { + throw new Error('IDEMPOTENCY_KEY_REUSE_MISMATCH'); + } + const existing = this.escrows.get(existingId); + if (existing) return existing; + } + } + + const amountRaw = xnoToRaw(input.amountXno); + const payerBalance = this.getBalanceRaw(input.payer); + if (payerBalance < amountRaw) { + throw new Error('Insufficient payer balance for escrow hold'); + } + + const representative = input.representative ?? this.representative; + const payerRemaining = payerBalance - amountRaw; + const escrowBalance = this.getBalanceRaw(this.escrowAccount) + amountRaw; + + const holdBlock = this.createBlock({ + type: 'send', + account: input.payer, + previous: NANO_CONFIG.zeroPrevious, + representative, + balance: payerRemaining, + link: this.escrowAccount, + amountRaw, + }); + + this.accountBalances.set(input.payer, payerRemaining); + this.accountBalances.set(this.escrowAccount, escrowBalance); + + const id = input.escrowId ?? `escrow_${randomUUID()}`; + const createdAt = this.timestamp(); + const escrow: NanoEscrow = { + id, + payer: input.payer, + payee: input.payee, + escrowAccount: this.escrowAccount, + representative, + amountXno: rawToXno(amountRaw), + amountRaw: amountRaw.toString(), + status: 'held', + holdBlock, + idempotencyKey: input.idempotencyKey, + metadata: input.metadata, + createdAt, + updatedAt: createdAt, + }; + + this.escrows.set(id, escrow); + if (input.idempotencyKey) { + this.idempotency.set(input.idempotencyKey, id); + this.idempotencyFingerprints.set(input.idempotencyKey, this.fingerprint(input)); + } + return escrow; + } + + /** + * Release funds: one feeless send from the escrow account to the payee. The + * release block is the confirmation, so the call is idempotent per escrow. + */ + releaseEscrow(escrowId: string, options: { approvedBy?: string } = {}): NanoEscrow { + const escrow = this.requireEscrow(escrowId); + if (escrow.status === 'released') return escrow; + if (escrow.status !== 'held') { + throw new Error(`Escrow ${escrowId} cannot be released from status ${escrow.status}`); + } + + const amountRaw = BigInt(escrow.amountRaw); + const escrowBalance = this.getBalanceRaw(this.escrowAccount); + if (escrowBalance < amountRaw) { + throw new Error('Escrow account is under-collateralised'); + } + + const payeeBalance = this.getBalanceRaw(escrow.payee) + amountRaw; + const releaseBlock = this.createBlock({ + type: 'send', + account: this.escrowAccount, + previous: escrow.holdBlock.hash, + representative: escrow.representative, + balance: escrowBalance - amountRaw, + link: escrow.payee, + amountRaw, + }); + + this.accountBalances.set(this.escrowAccount, escrowBalance - amountRaw); + this.accountBalances.set(escrow.payee, payeeBalance); + + escrow.status = 'released'; + escrow.releaseBlock = releaseBlock; + escrow.releasedAt = this.timestamp(); + escrow.updatedAt = escrow.releasedAt; + escrow.approvedBy = options.approvedBy; + this.escrows.set(escrowId, escrow); + return escrow; + } + + /** + * Refund funds: one feeless send from the escrow account back to the payer. + */ + refundEscrow(escrowId: string, options: { reason?: string } = {}): NanoEscrow { + const escrow = this.requireEscrow(escrowId); + if (escrow.status === 'refunded') return escrow; + if (escrow.status !== 'held') { + throw new Error(`Escrow ${escrowId} cannot be refunded from status ${escrow.status}`); + } + + const amountRaw = BigInt(escrow.amountRaw); + const escrowBalance = this.getBalanceRaw(this.escrowAccount); + const payerBalance = this.getBalanceRaw(escrow.payer) + amountRaw; + + const refundBlock = this.createBlock({ + type: 'send', + account: this.escrowAccount, + previous: escrow.holdBlock.hash, + representative: escrow.representative, + balance: escrowBalance - amountRaw, + link: escrow.payer, + amountRaw, + }); + + this.accountBalances.set(this.escrowAccount, escrowBalance - amountRaw); + this.accountBalances.set(escrow.payer, payerBalance); + + escrow.status = 'refunded'; + escrow.refundBlock = refundBlock; + escrow.refundedAt = this.timestamp(); + escrow.updatedAt = escrow.refundedAt; + if (options.reason) escrow.metadata = { ...(escrow.metadata ?? {}), refundReason: options.reason }; + this.escrows.set(escrowId, escrow); + return escrow; + } + + getEscrow(escrowId: string): NanoEscrow | null { + return this.escrows.get(escrowId) ?? null; + } + + listEscrows(filter?: { status?: NanoEscrowStatus; payer?: string; payee?: string }): NanoEscrow[] { + let list = Array.from(this.escrows.values()); + if (filter?.status) list = list.filter((e) => e.status === filter.status); + if (filter?.payer) list = list.filter((e) => e.payer === filter.payer); + if (filter?.payee) list = list.filter((e) => e.payee === filter.payee); + return list.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + /** Look up a block by hash across every escrow. */ + getBlock(hash: string): NanoBlock | null { + for (const escrow of this.escrows.values()) { + if (escrow.holdBlock.hash === hash) return escrow.holdBlock; + if (escrow.releaseBlock?.hash === hash) return escrow.releaseBlock; + if (escrow.refundBlock?.hash === hash) return escrow.refundBlock; + } + return null; + } + + /** + * Verify a release block as a public receipt, without trusting either party. + */ + verifyEscrowReceipt(escrowId: string): NanoReceiveVerification { + const escrow = this.requireEscrow(escrowId); + const block = escrow.releaseBlock; + return { + escrowId, + status: escrow.status, + blockHash: block?.hash, + to: block?.link, + amountXno: block?.amountXno, + feeless: true, + finalityMs: this.finalityMs, + verified: escrow.status === 'released' && Boolean(block?.confirmed), + explorerHint: block ? `nano://block/${block.hash}` : 'nano://pending', + }; + } + + getMetrics(): NanoRailMetrics { + const escrows = Array.from(this.escrows.values()); + return { + escrows: escrows.length, + held: escrows.filter((e) => e.status === 'held').length, + released: escrows.filter((e) => e.status === 'released').length, + refunded: escrows.filter((e) => e.status === 'refunded').length, + feesCollectedRaw: '0', + feesCollectedXno: 0, + }; + } + + private requireEscrow(escrowId: string): NanoEscrow { + const escrow = this.escrows.get(escrowId); + if (!escrow) throw new Error(`Escrow not found: ${escrowId}`); + return escrow; + } + + private fingerprint(input: HoldEscrowInput): string { + return [ + input.payer, + input.payee, + xnoToRaw(input.amountXno).toString(), + input.representative ?? this.representative, + ].join('|'); + } +} + +/** + * Factory used by the API layer so the escrow rail can be swapped per environment. + */ +export function createNanoSettleRail(config: NanoRailConfig = {}): NanoSettleRail { + return new NanoSettleRail(config); +} + +/** Pick a settlement rail for an asset; Nano (XNO) routes to this rail. */ +export function resolveSettlementRail(asset: string): SettlementRailId { + return asset.toUpperCase() === NANO_CONFIG.ticker ? 'nano' : 'stellar'; +} diff --git a/docs/AML_TRANSACTION_MONITORING.md b/docs/AML_TRANSACTION_MONITORING.md new file mode 100644 index 00000000..80d184a8 --- /dev/null +++ b/docs/AML_TRANSACTION_MONITORING.md @@ -0,0 +1,123 @@ +# AML Transaction Monitoring with Risk Scoring + +Issue: **#922 — Implement AML transaction monitoring with risk scoring** + +AgenticPay screens every payment against a deterministic, explainable AML rule +engine. Each transaction receives a `0-100` risk score, a risk level, the list of +rules that fired, and a recommended action. High and critical assessments +generate alerts that can be triaged through the API. + +## Implementation + +| Layer | Location | +| --- | --- | +| Rule engine + scoring | `backend/src/services/aml/transaction-monitor.ts` | +| HTTP API | `backend/src/routes/aml-monitoring.ts` (mounted at `/api/v1/aml`) | +| Tests | `backend/src/services/aml/__tests__/transaction-monitor.test.ts` | + +## Risk levels + +Scores are capped at `100` and mapped to levels: + +| Level | Score band | Recommended action | +| --- | --- | --- | +| `low` | `0-19` | `allow` | +| `medium` | `20-39` | `monitor` | +| `high` | `40-69` | `review` | +| `critical` | `70-100` | `block_and_report` | + +Any assessment that is `critical` **or** that matches the sanctions-exposure +rule sets `requiresSarFiling: true`, prompting a Suspicious Activity Report. + +## Rules + +Every rule contributes a fixed weight to the score. Weights are additive and the +total is clamped to `100`, so multiple weak signals compound into a stronger +recommendation. + +| Rule | Weight | What it catches | +| --- | --- | --- | +| `sanctions_exposure` | 70 | Counterparty appears on a sanctions watchlist | +| `pep_exposure` | 30 | Counterparty is a politically exposed person | +| `structuring` | 35 | Amounts just below the CTR threshold, or repeated near-threshold activity | +| `ctr_threshold_breach` | 20 | Amount meets/exceeds the CTR reporting threshold (`$10,000`) | +| `unusually_large_amount` | 15 | Amount above the high-value monitoring threshold (`$25,000`) | +| `velocity_per_hour` | 25 | More than 10 transactions from the account in one hour | +| `velocity_per_day` | 15 | More than 40 transactions from the account in 24 hours | +| `fan_out` | 20 | More than 10 distinct counterparties in 24 hours | +| `amount_spike` | 15 | Amount at least 5x the account baseline | +| `unverified_account` | 25 | Account has not completed KYC | +| `new_account_high_value` | 15 | Account under 30 days old moving high-value amounts | +| `unusual_hour` | 10 | Activity between 00:00-06:00 UTC | +| `round_amount` | 5 | Large round-number transfer | +| `unknown_counterparty` | 10 | First-time counterparty | +| `high_risk_jurisdiction` | 20 | Origin or destination on the high-risk country list | + +Call `GET /api/v1/aml/rules` for the live catalogue and thresholds. + +## API + +### `POST /api/v1/aml/score` + +Scores a single transaction, optionally with account context. + +```json +{ + "transaction": { + "transactionId": "tx_1", + "accountId": "acct_1", + "counterpartyId": "cp_1", + "amount": 9500, + "currency": "USD", + "timestamp": 1767225600000 + }, + "account": { + "accountId": "acct_1", + "kycVerified": true, + "knownCounterparties": ["cp_1"] + } +} +``` + +Response (`data`) is an `AmlAssessment`: + +```json +{ + "transactionId": "tx_1", + "accountId": "acct_1", + "score": 35, + "level": "medium", + "matches": [{ "ruleId": "structuring", "weight": 35, "severity": "high" }], + "recommendedAction": "monitor", + "requiresSarFiling": false, + "assessedAt": "2026-01-02T14:30:00.000Z" +} +``` + +### `POST /api/v1/aml/monitor` + +Batch endpoint. Scores `transactions` (with an optional `accounts` context array) +and automatically creates alerts for `high`/`critical` assessments. + +### `GET /api/v1/aml/alerts` + +Lists alerts, newest first. Supports `?status=`, `?severity=` and `?accountId=` +filters. + +### `PATCH /api/v1/aml/alerts/:id` + +Moves an alert to `under_review`, `resolved`, or `dismissed`: + +```json +{ "status": "resolved", "reviewedBy": "analyst-1", "resolution": "legitimate payroll" } +``` + +Returns `404` when the alert does not exist and rejects re-finalising an +already-resolved alert. + +## Notes and limitations + +- The alert store is in-memory; wire it to the database for durable retention. +- The rule engine is intentionally synchronous and dependency-free so it can run + on the payment hot path without blocking. +- Thresholds live in `AML_THRESHOLDS` and are exposed read-only through the API. diff --git a/docs/NANO_SETTLE_RAIL.md b/docs/NANO_SETTLE_RAIL.md new file mode 100644 index 00000000..52fcdcd3 --- /dev/null +++ b/docs/NANO_SETTLE_RAIL.md @@ -0,0 +1,94 @@ +# Nano (XNO) Settle Rail + +Issue: **#1004 — A Nano (XNO) settle rail: escrow release and instant payment as one feeless block** + +AgenticPay's escrow model is rail-agnostic: something holds funds until a +milestone is approved, then a single release moves them. On Stellar/Soroban that +release is a contract call. On **Nano (XNO)** the same "hold then release" is two +feeless sends, with no smart-contract execution, no gas and no submission fee. + +- `hold` — one feeless send from the payer to the escrow/agent account. +- `release` — one feeless send from the escrow/agent account to the payee. + +The release block **is** the confirmation: it is final in roughly a second, and +because the ledger is the public receipt, an independent party can verify the +destination, amount and ordering of the transfer without trusting either party. +No Horizon-style indexer is required. + +## Implementation + +| Layer | Location | +| --- | --- | +| Rail | `backend/src/services/settlement/nano-settle-rail.ts` | +| HTTP API | `backend/src/routes/nano.ts` (mounted at `/api/v1/nano`) | +| Tests | `backend/src/services/settlement/__tests__/nano-settle-rail.test.ts` | + +`resolveSettlementRail(asset)` returns `'nano'` for `XNO` and `'stellar'` +otherwise, so a payment can select the rail next to the existing Soroban escrow +path. + +## Amounts and addresses + +- Nano amounts are expressed as decimal XNO. The smallest unit, *raw*, is + `10^-30` XNO. Helpers: `xnoToRaw()` and `rawToXno()`. +- Addresses are validated with `isValidNanoAddress()` (`nano_`/`xrb_` plus 60 + base32 characters). + +## API + +### `GET /api/v1/nano/status` + +Returns `rail: "nano"`, `ticker: "XNO"`, `feeless: true`, the simulated +`finalityMs`, the supported rail list, and rail metrics. + +### `POST /api/v1/nano/escrows` — hold + +```json +{ + "payer": "nano_3111...hifc8npp", + "payee": "nano_3t6k35gi...uohr3", + "amountXno": 25, + "idempotencyKey": "milestone-42", + "metadata": { "projectId": "proj_1" } +} +``` + +Returns `201` with a `NanoEscrow` containing the `holdBlock`. An +`idempotencyKey` replays the original hold; reusing it with different details +fails with `IDEMPOTENCY_KEY_REUSE_MISMATCH`. + +### `POST /api/v1/nano/escrows/:id/release` — release + +```json +{ "approvedBy": "agent-7" } +``` + +Creates the release send block, marks the escrow `released`, and is idempotent — +calling it again returns the same block and never double-credits the payee. + +### `POST /api/v1/nano/escrows/:id/refund` — refund + +```json +{ "reason": "milestone rejected" } +``` + +Sends the held amount back to the payer. + +### Read endpoints + +- `GET /api/v1/nano/escrows?status=&payer=&payee=` — list escrows. +- `GET /api/v1/nano/escrows/:id` — fetch an escrow. +- `GET /api/v1/nano/escrows/:id/receipt` — verify the public release receipt + (`verified`, destination, amount, `feeless`, `finalityMs`, explorer hint). +- `GET /api/v1/nano/blocks/:hash` — look up any hold/release/refund block. +- `POST /api/v1/nano/fund` — sandbox/faucet helper to fund a simulated account. + +## Notes and limitations + +- Block hashes and signatures are **deterministic simulations** so the rail can + be unit tested offline; real broadcast uses Nano's `block_create`/`process` + RPC and BLAKE2b signatures. Wire `NanoSettleRail` to a node for production. +- The escrow/agent account and balances are held in memory for this + implementation; persist them alongside the existing escrow records. +- Nano transfers are feeless regardless of amount; `getMetrics()` reports + `feesCollectedRaw: "0"`. diff --git a/docs/PCI_DSS_DASHBOARD.md b/docs/PCI_DSS_DASHBOARD.md new file mode 100644 index 00000000..182a28bc --- /dev/null +++ b/docs/PCI_DSS_DASHBOARD.md @@ -0,0 +1,71 @@ +# PCI DSS Compliance Dashboard + +Issue: **#924 — Build PCI DSS compliance dashboard** + +The PCI DSS dashboard turns a catalogue of PCI DSS v4.0 requirements and controls +into an evidence-backed compliance posture: a per-requirement status, a weighted +overall score, evidence coverage, and a prioritised remediation backlog. + +## Implementation + +| Layer | Location | +| --- | --- | +| Assessment engine | `backend/src/services/pci-dss.ts` | +| HTTP API | `backend/src/routes/pci-dss.ts` (mounted at `/api/v1/pci-dss`) | +| Dashboard UI | `frontend/app/dashboard/compliance/pci-dss/page.tsx` | +| Tests | `backend/src/services/__tests__/pci-dss.test.ts` | + +## Model + +The catalogue defines the twelve PCI DSS v4.0 top-level **requirements**, each +with a set of **controls** that carry a priority, a framework reference and +remediation guidance. Every control has one of four statuses: + +| Control status | Meaning | +| --- | --- | +| `in_place` | Control is implemented and evidenced | +| `not_in_place` | Control is missing or failed | +| `not_tested` | Control exists but has not been verified | +| `not_applicable` | Control is out of scope for this environment | + +Requirement status is derived from its controls: + +- any `not_in_place` → `non_compliant` +- else any `not_tested` → `in_place_with_deficiencies` +- else (all `in_place`) → `compliant` +- all controls `not_applicable` → `not_applicable` + +`compliancePercent` is the share of applicable controls that are `in_place`. The +dashboard's `overallScore` is the requirement-weighted average of those +percentages, and `evidenceCoverage` is the share of applicable controls that have +been tested (`in_place` or `not_in_place` rather than `not_tested`). + +## API + +- `GET /api/v1/pci-dss/dashboard` — full dashboard (score, counts, requirements, gaps). + Optional `?assessedBy=` for the report attribution. +- `GET /api/v1/pci-dss/requirements` — per-requirement assessments with controls. +- `GET /api/v1/pci-dss/gaps?priority=high|medium|low` — prioritised gaps. +- `GET /api/v1/pci-dss/controls/:id` — a single control (`404` when unknown). +- `PATCH /api/v1/pci-dss/controls/:id` — record evidence: + + ```json + { "status": "in_place", "evidence": "KMS rotation documented" } + ``` + +- `POST /api/v1/pci-dss/reset` — restore the default sample posture. +- `GET /api/v1/pci-dss/export/evidence.csv` — CSV of every control and its evidence. + +## UI + +The dashboard page at `/dashboard/compliance/pci-dss` shows the overall score, +requirement-level status table, and the prioritised remediation gap list. It +reads the backend dashboard endpoint and refreshes on demand. + +## Notes and limitations + +- Control statuses are held in memory and seeded with a realistic sample posture + so the dashboard renders immediately. Wire `setControlStatus()` to the audit + trail / database for durable, attested evidence. +- The catalogue is a representative subset of PCI DSS v4.0 controls intended for + dashboarding; it is not a substitute for a formal Report on Compliance (ROC). diff --git a/docs/README.md b/docs/README.md index 751a9676..1d9f3ed1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Use this page as the entry point for the repository's maintained guides. API-spe - [Instant transfers](INSTANT_TRANSFERS.md) - [Marketplace escrow](MARKETPLACE_ESCROW.md) +- [Nano (XNO) settle rail](NANO_SETTLE_RAIL.md) - [Payment links](PAYMENT_LINKS.md) - [Payment correctness](PAYMENT_CORRECTNESS.md) - [Payment provider routing](PAYMENT_ROUTING.md) @@ -41,6 +42,9 @@ Use this page as the entry point for the repository's maintained guides. API-spe - [Security policy](security-policy.md) - [Audit logging](security/audit-logging.md) - [Provider credentials encryption](security/provider-credentials-encryption.md) +- [AML transaction monitoring](AML_TRANSACTION_MONITORING.md) +- [Sanctions screening](SANCTIONS_SCREENING.md) +- [PCI DSS compliance dashboard](PCI_DSS_DASHBOARD.md) - [Configuration reference](config/CONFIGURATION.md) ## Keeping this index healthy diff --git a/docs/SANCTIONS_SCREENING.md b/docs/SANCTIONS_SCREENING.md new file mode 100644 index 00000000..63b3a4d0 --- /dev/null +++ b/docs/SANCTIONS_SCREENING.md @@ -0,0 +1,91 @@ +# Sanctions Screening against OFAC Lists + +Issue: **#923 — Add sanctions screening against OFAC lists** + +AgenticPay screens counterparties and transactions against a watchlist that +models the OFAC Specially Designated Nationals (SDN) and consolidated lists, plus +EU, UN and UK OFSI lists. Screening is fuzzy: names are normalised and compared +with token and edit-distance similarity, then corroborated by strong identifiers +so transliteration, ordering and honorific differences do not hide a real hit. + +## Implementation + +| Layer | Location | +| --- | --- | +| Screening engine | `backend/src/services/sanctions/ofac-screening.ts` | +| HTTP API | `backend/src/routes/sanctions.ts` (mounted at `/api/v1/sanctions`) | +| Tests | `backend/src/services/sanctions/__tests__/ofac-screening.test.ts` | + +## Matching algorithm + +1. **Normalise** — strip diacritics and punctuation, lowercase, collapse + whitespace, and drop honorifics/corporate noise tokens (`mr`, `ltd`, `llc`, + `gmbh`, `pjsc`, …). `Ivanov, Sergei Petrovich` → `ivanov sergei petrovich`. +2. **Compare** — for every entity name and alias, compute the maximum of: + - edit similarity (`1 - levenshtein / maxLength`), and + - token-set similarity (Jaccard and token containment). +3. **Corroborate** — + - an exact national-id match is decisive and forces a score of `1.0`; + - a matching date of birth adds `+0.10`, a mismatch subtracts `0.05`; + - a shared country adds `+0.05`; + - an entity-type mismatch subtracts `0.10`. +4. **Classify** against the thresholds (defaults): + - `score >= 0.85` → `match` + - `0.65 <= score < 0.85` → `potential_match` + - otherwise → `clear` + +## API + +### `POST /api/v1/sanctions/screen` + +```json +{ + "query": { "name": "Sergey Ivanov", "countries": ["RU"] }, + "options": { "threshold": 0.85, "potentialThreshold": 0.65, "lists": ["OFAC_SDN"] } +} +``` + +Returns a `SanctionsScreeningResult` with `status`, ranked `matches` (each with a +`score`, matched name and `reasons`) and the thresholds used. + +### `POST /api/v1/sanctions/screen/transaction` + +Screens a payer and/or payee together and escalates to the highest status: + +```json +{ + "transactionId": "tx_1", + "payer": { "name": "Alice Wonderland" }, + "payee": { "name": "Ivanov, Sergei Petrovich" } +} +``` + +### Watchlist management + +- `GET /sanctions/lists` — supported lists and current watchlist size. +- `GET /sanctions/entities?list=&entityType=&country=` — list entries. +- `GET /sanctions/entities/:id` — fetch a single entry (`404` if absent). +- `POST /sanctions/entities` — add or replace an entry. +- `DELETE /sanctions/entities/:id` — remove an entry (`404` if absent). +- `POST /sanctions/reload` — bulk-load entries, optionally replacing the list. + +### `GET /api/v1/sanctions/history` + +Returns recent screening decisions, filterable by `status` and `since`. + +## Data source and refresh + +The service seeds itself with **illustrative sample entries** so the engine is +usable out of the box. These are fictional fixtures and are not the real OFAC +list. Load production data with `loadSanctionsEntries(entries, { replace: true })` +(or `POST /sanctions/reload`) from a scheduled refresh job that downloads and +parses the official list files. + +## Notes and limitations + +- The watchlist and screening history are in-memory; wire them to durable storage + before production use. +- Screening is deterministic and dependency-free so it can run inline on the + payment path. +- Fuzzy matching intentionally favours recall (fewer false negatives); always + require human review before freezing funds or filing reports. diff --git a/frontend/app/dashboard/compliance/pci-dss/page.tsx b/frontend/app/dashboard/compliance/pci-dss/page.tsx new file mode 100644 index 00000000..2cbb71e3 --- /dev/null +++ b/frontend/app/dashboard/compliance/pci-dss/page.tsx @@ -0,0 +1,222 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; + +// ─── Types (mirrors backend services/pci-dss.ts) ───────────────────────────── + +type ControlStatus = 'in_place' | 'not_in_place' | 'not_tested' | 'not_applicable'; +type RequirementStatus = 'compliant' | 'in_place_with_deficiencies' | 'non_compliant' | 'not_applicable'; +type Priority = 'high' | 'medium' | 'low'; + +interface PciDssGap { + controlId: string; + controlTitle: string; + requirementId: number; + requirementTitle: string; + status: Exclude; + priority: Priority; + reference: string; + remediation: string; +} + +interface PciDssControl { + id: string; + title: string; + status: ControlStatus; + priority: Priority; + reference: string; +} + +interface PciDssRequirement { + id: number; + title: string; + category: string; + weight: number; + status: RequirementStatus; + compliancePercent: number; + controls: PciDssControl[]; + gaps: PciDssGap[]; +} + +interface PciDssDashboard { + version: string; + generatedAt: string; + overallScore: number; + overallStatus: RequirementStatus; + requirementStatusCounts: Record; + controlStatusCounts: Record; + evidenceCoverage: number; + requirements: PciDssRequirement[]; + gaps: PciDssGap[]; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; + +const STATUS_STYLES: Record = { + compliant: 'bg-green-100 text-green-800', + in_place: 'bg-green-100 text-green-800', + in_place_with_deficiencies: 'bg-yellow-100 text-yellow-800', + not_tested: 'bg-gray-100 text-gray-800', + non_compliant: 'bg-red-100 text-red-800', + not_in_place: 'bg-red-100 text-red-800', + not_applicable: 'bg-gray-100 text-gray-500', +}; + +const PRIORITY_STYLES: Record = { + high: 'bg-red-100 text-red-800', + medium: 'bg-yellow-100 text-yellow-800', + low: 'bg-gray-100 text-gray-800', +}; + +function Badge({ value }: { value: string }) { + return ( + + {value.replace(/_/g, ' ')} + + ); +} + +export default function PciDssDashboardPage() { + const [dashboard, setDashboard] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await fetch(`${API_BASE}/pci-dss/dashboard`); + if (!res.ok) throw new Error(`API error ${res.status}`); + const body = (await res.json()) as { data: PciDssDashboard }; + setDashboard(body.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load PCI DSS dashboard'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + if (loading && !dashboard) { + return ( +
+
+ Loading PCI DSS dashboard… +
+ ); + } + + return ( +
+
+
+

PCI DSS Compliance Dashboard

+

+ PCI DSS v{dashboard?.version} · Evidence coverage {dashboard?.evidenceCoverage ?? 0}% +

+
+ +
+ + {error && ( +
+

{error}

+
+ )} + + {dashboard && ( + <> +
+
+

Overall Score

+

{dashboard.overallScore}%

+
+
+
+

Compliant Requirements

+

{dashboard.requirementStatusCounts.compliant}

+

of {dashboard.requirements.length}

+
+
+

Non-compliant

+

{dashboard.requirementStatusCounts.non_compliant}

+

requirements

+
+
+

Open Gaps

+

{dashboard.gaps.length}

+

+ {dashboard.controlStatusCounts.not_in_place} not in place +

+
+
+ +
+
+

Requirements

+
+
+ + + + + + + + + + + {dashboard.requirements.map((requirement) => ( + + + + + + + ))} + +
#RequirementComplianceStatus
{requirement.id}{requirement.title} + {requirement.compliancePercent.toFixed(1)}% +
+
+
+ +
+
+

Remediation Gaps

+
+ {dashboard.gaps.length === 0 ? ( +
+ No open gaps — all in-scope controls are in place. +
+ ) : ( +
    + {dashboard.gaps.map((gap) => ( +
  • +
    + + {gap.priority} + + Req {gap.requirementId} · {gap.controlId} + +
    +

    {gap.controlTitle}

    +

    {gap.remediation} ({gap.reference})

    +
  • + ))} +
+ )} +
+ + )} +
+ ); +}