diff --git a/backend/.env.example b/backend/.env.example index fa2eaa36..ca312142 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -147,6 +147,93 @@ OTEL_EXPORTER_OTLP_ENDPOINT= # Ledgers a POST /v1/streams/simulate footprint stays valid for (default: 10) SIMULATION_VALIDITY_LEDGERS=10 +# ─── Compliance / Sanctions Screening (Issue #1470) ─────────────────────────── +# Master switch for the sanctions / OFAC screening middleware that guards stream +# creation, top-up (deposit) and withdrawal. Defaults to false so local testing +# and self-hosted deployments stay fully permissive. +COMPLIANCE_ENFORCEMENT_ENABLED=false + +# Addresses with a risk score strictly greater than this threshold (0-100) are +# blocked alongside explicitly sanctioned addresses (default: 70) +COMPLIANCE_RISK_THRESHOLD=70 + +# How to react when the screening provider itself fails: +# fail-closed (default) -> block the request with 503 +# fail-open -> allow the request and record an audit event +COMPLIANCE_FAILURE_MODE=fail-closed + +# Time-to-live in seconds for cached screening results (minimize provider cost) +COMPLIANCE_CACHE_TTL_SECONDS=86400 + +# Screening data source: 'local' (in-process lists) or 'external' (provider API) +COMPLIANCE_PROVIDER=local + +# Comma-separated always-allowed addresses (checked before the denylist) +COMPLIANCE_ALLOWLIST= + +# Comma-separated always-blocked addresses (useful for local testing) +COMPLIANCE_DENYLIST= + +# Optional path to a JSON sanctions feed (OFAC SDN export). Accepts an array of +# addresses, an array of { address, tags } records, or an { addresses: [] } map. +COMPLIANCE_SANCTIONS_FILE= + +# External provider settings (required when COMPLIANCE_PROVIDER=external) +COMPLIANCE_EXTERNAL_API_URL= +COMPLIANCE_EXTERNAL_API_KEY= +COMPLIANCE_EXTERNAL_API_TIMEOUT_MS=5000 + +# ─── Sentinel anomaly detection / drain sentinel (Issue #1469) ──────────────── +# Real-time velocity analysis over indexed stream lifecycle events. Master +# switch for the sentinel; when false the indexer stops feeding it and no +# anomalies are recorded. Defaults to true. +SENTINEL_ENABLED=true + +# Rolling window the current velocity is measured over (milliseconds, default 60000) +SENTINEL_VELOCITY_WINDOW_MS=60000 +# Baseline window a spike is compared against (milliseconds, default 86400000) +SENTINEL_BASELINE_WINDOW_MS=86400000 +# Spike factor: current window / baseline average. 4 == a 300% increase (default 4) +SENTINEL_VELOCITY_SPIKE_MULTIPLIER=4 +# Minimum events in the window before a velocity spike can fire (default 5) +SENTINEL_VELOCITY_MIN_EVENTS=5 + +# Distinct streams a single address may drain before MULTI_STREAM_DRAIN fires (default 20) +SENTINEL_MULTI_STREAM_MAX=20 +# Ledgers the multi-stream drain window spans (default 3) +SENTINEL_MULTI_STREAM_WINDOW_LEDGERS=3 + +# Single-withdrawal notional (USD) treated as high-value (default 100000). +# Evaluated only when the caller supplies a USD valuation (`amountUsd`). +SENTINEL_HIGH_VALUE_THRESHOLD_USD=100000 +# Percentage of a stream's deposit a single withdrawal may consume (default 80) +SENTINEL_BALANCE_DRAIN_PCT=80 + +# Stream creations per token inside the spike window before alerting (default 25) +SENTINEL_CREATION_SPIKE_THRESHOLD=25 +# Creation spike window (milliseconds, default 300000) +SENTINEL_CREATION_SPIKE_WINDOW_MS=300000 +# A stream with less runway than this counts toward the zero-runway flood (seconds, default 60) +SENTINEL_ZERO_RUNWAY_MAX_SECONDS=60 +# Zero-runway flood window (milliseconds, default 300000) +SENTINEL_ZERO_RUNWAY_WINDOW_MS=300000 +# Near-zero-runway streams in the window that constitute a flood (default 50) +SENTINEL_ZERO_RUNWAY_FLOOD_THRESHOLD=50 + +# Suppress repeat alerts for the same rule + subject within this window (ms, default 60000) +SENTINEL_ALERT_COOLDOWN_MS=60000 +# Incident history retention (milliseconds, default 86400000) +SENTINEL_RETENTION_MS=86400000 +# Trailing window the aggregate 0-100 threat score is computed over (ms, default 900000) +SENTINEL_THREAT_WINDOW_MS=900000 + +# Unified alert fan-out. Any subset may be configured; unset channels are skipped. +SENTINEL_SLACK_WEBHOOK_URL= +SENTINEL_DISCORD_WEBHOOK_URL= +SENTINEL_PAGERDUTY_ROUTING_KEY= +# Secret used to HMAC-sign emergency pause proposals. Falls back to JWT_SECRET. +SENTINEL_PROPOSAL_SECRET= + # ─── Admin dead-letter quarantine ───────────────────────────────────────────── # No configuration required. Quarantined indexer events are managed through: # GET /v1/admin/indexer/dead-letter diff --git a/backend/prisma/migrations/20260928000000_add_compliance_tables/migration.sql b/backend/prisma/migrations/20260928000000_add_compliance_tables/migration.sql new file mode 100644 index 00000000..7e5a8017 --- /dev/null +++ b/backend/prisma/migrations/20260928000000_add_compliance_tables/migration.sql @@ -0,0 +1,57 @@ +-- Compliance / sanctions screening schema (Issue #1470) +-- +-- ComplianceAuditLog: immutable audit trail for screening decisions. +-- Every blocked or failed screening records the address, request IP, risk +-- score and provider tags so enterprise deployments can prove wallets were +-- screened before funds moved. +-- KycAttestation: SEP-0009 KYC/AML identity attestations submitted by +-- organizations, stored with their cryptographic proof. + +-- CreateTable +CREATE TABLE "ComplianceAuditLog" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "action" TEXT, + "address" TEXT NOT NULL, + "ipAddress" TEXT, + "riskScore" INTEGER NOT NULL, + "tags" TEXT[], + "isSanctioned" BOOLEAN NOT NULL DEFAULT false, + "metadata" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ComplianceAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "KycAttestation" ( + "id" TEXT NOT NULL, + "organization" TEXT NOT NULL, + "subjectAddress" TEXT NOT NULL, + "sep9Fields" TEXT NOT NULL, + "proof" TEXT NOT NULL, + "proofType" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "KycAttestation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ComplianceAuditLog_address_idx" ON "ComplianceAuditLog"("address"); + +-- CreateIndex +CREATE INDEX "ComplianceAuditLog_eventType_idx" ON "ComplianceAuditLog"("eventType"); + +-- CreateIndex +CREATE INDEX "ComplianceAuditLog_createdAt_idx" ON "ComplianceAuditLog"("createdAt"); + +-- CreateIndex +CREATE INDEX "KycAttestation_organization_idx" ON "KycAttestation"("organization"); + +-- CreateIndex +CREATE INDEX "KycAttestation_subjectAddress_idx" ON "KycAttestation"("subjectAddress"); + +-- CreateIndex +CREATE INDEX "KycAttestation_status_idx" ON "KycAttestation"("status"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 06973e76..24811c4b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -153,6 +153,47 @@ model AlertHistory { @@index([sentAt]) } +// ComplianceAuditLog model - immutable security audit trail for sanctions / +// risk screening outcomes (Issue #1470). Every blocked interaction records the +// originating address, request IP, risk score and provider tags so enterprise +// deployments have audit-ready proof that wallets were screened before funds +// moved. +model ComplianceAuditLog { + id String @id @default(uuid()) + eventType String // e.g. "SCREENING_BLOCKED", "SCREENING_ERROR", "KYC_ATTESTATION_SUBMITTED" + action String? // Route action, e.g. "stream.create" + address String // Stellar address that was screened + ipAddress String? // Originating request IP + riskScore Int // 0 (clean) to 100 (blocked) + tags String[] // Provider tags, e.g. ["OFAC", "Darknet"] + isSanctioned Boolean @default(false) + metadata String? // JSON string for extra screening context + createdAt DateTime @default(now()) + + @@index([address]) + @@index([eventType]) + @@index([createdAt]) +} + +// KycAttestation model - SEP-0009 identity attestations submitted by +// organizations. Stores the standardised KYC/AML field payload alongside the +// cryptographic proof so a deployment can prove a recipient was verified. +model KycAttestation { + id String @id @default(uuid()) + organization String // Organization / wallet that submitted the attestation + subjectAddress String // Wallet the identity belongs to + sep9Fields String // JSON string of the SEP-0009 KYC/AML field payload + proof String // Cryptographic proof / signature over the payload + proofType String? // Signature scheme, e.g. "ed25519" + status String @default("PENDING") // PENDING | VERIFIED | REJECTED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organization]) + @@index([subjectAddress]) + @@index([status]) +} + // IndexerDeadLetterEvent model - quarantines Soroban events whose processing // failed (unexpected payload shape, transient DB lock, RPC timeout mid-handler). // A quarantined event is never retried inline by the poll loop, so one malformed diff --git a/backend/src/config/compliance.config.ts b/backend/src/config/compliance.config.ts new file mode 100644 index 00000000..40b8ce38 --- /dev/null +++ b/backend/src/config/compliance.config.ts @@ -0,0 +1,206 @@ +/** + * Compliance / Sanctions Screening Configuration (Issue #1470) + * + * FlowFi is used for institutional payroll and regulated token distributions on + * Stellar. Before any stream is funded or withdrawn, the API can screen the + * participating wallet addresses against sanctions data (OFAC SDN and friends) + * and a configurable risk threshold. + * + * Every setting is opt-in so the open-source default stays fully permissive: + * `COMPLIANCE_ENFORCEMENT_ENABLED` defaults to `false`, preserving local + * testing and self-hosted sovereignty. + */ + +/** + * How the middleware reacts when the screening provider itself fails + * (network error, timeout, malformed response). + * + * - `fail-closed`: block the request (safe default for regulated deployments). + * - `fail-open`: allow the request and record an audit event. + */ +export type ComplianceFailureMode = 'fail-open' | 'fail-closed'; + +/** + * Screening data source. + * + * - `local`: in-process denylist / allowlist (inline env + optional JSON + * sanctions feed). Zero external dependencies. + * - `external`: a screening provider (Chainalysis, TRM Labs, Elliptic, …) + * reached over HTTPS. + */ +export type ComplianceProviderName = 'local' | 'external'; + +export interface ComplianceConfig { + /** Master switch. When false the middleware is a no-op pass-through. */ + enforcementEnabled: boolean; + /** Addresses with `riskScore > riskThreshold` are blocked (0–100). */ + riskThreshold: number; + /** Behaviour when the screening provider errors out. */ + failureMode: ComplianceFailureMode; + /** TTL (seconds) for cached screening results, default 24h. */ + cacheTtlSeconds: number; + /** Screening data source. */ + provider: ComplianceProviderName; + /** External provider endpoint (required when provider === 'external'). */ + externalApiUrl?: string; + /** Bearer/API key forwarded to the external provider. */ + externalApiKey?: string; + /** External provider request timeout in milliseconds. */ + externalApiTimeoutMs: number; + /** Always-allowed addresses (checked before the denylist). */ + allowlist: string[]; + /** Always-blocked addresses. */ + denylist: string[]; + /** Optional path to a JSON sanctions feed loaded at runtime. */ + sanctionsFilePath?: string; +} + +const DEFAULT_RISK_THRESHOLD = 70; +const DEFAULT_CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours +const DEFAULT_EXTERNAL_TIMEOUT_MS = 5_000; + +/** + * Strictly validate boolean environment variables. + * + * Accepted values are exactly `'true'` / `'false'`; anything else throws so a + * typo can never silently disable compliance enforcement. + */ +function parseBooleanEnv( + varName: string, + value: string | undefined, + defaultValue: boolean, +): boolean { + if (value === undefined || value === '') { + return defaultValue; + } + + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + throw new Error( + `${varName} has invalid value '${value}'. Expected one of: 'true', 'false'.`, + ); +} + +function parseNumberEnv( + varName: string, + value: string | undefined, + defaultValue: number, + { min, max }: { min: number; max: number }, +): number { + if (value === undefined || value === '') { + return defaultValue; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new Error(`${varName} has invalid value '${value}'. Expected an integer.`); + } + + if (parsed < min || parsed > max) { + throw new Error( + `${varName} has invalid value '${value}'. Expected an integer between ${min} and ${max}.`, + ); + } + + return parsed; +} + +function parseFailureMode(value: string | undefined): ComplianceFailureMode { + if (value === undefined || value === '') { + return 'fail-closed'; + } + if (value === 'fail-open' || value === 'fail-closed') { + return value; + } + throw new Error( + `COMPLIANCE_FAILURE_MODE has invalid value '${value}'. Expected one of: 'fail-open', 'fail-closed'.`, + ); +} + +function parseProvider(value: string | undefined): ComplianceProviderName { + if (value === undefined || value === '') { + return 'local'; + } + if (value === 'local' || value === 'external') { + return value; + } + throw new Error( + `COMPLIANCE_PROVIDER has invalid value '${value}'. Expected one of: 'local', 'external'.`, + ); +} + +function parseAddressList(value: string | undefined): string[] { + if (!value) { + return []; + } + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** + * Resolve compliance configuration from the environment. + * + * Read at call time (not module load) so tests and long-running processes can + * toggle enforcement without a restart. + */ +export function getComplianceConfig(): ComplianceConfig { + const config: ComplianceConfig = { + enforcementEnabled: parseBooleanEnv( + 'COMPLIANCE_ENFORCEMENT_ENABLED', + process.env.COMPLIANCE_ENFORCEMENT_ENABLED, + false, + ), + riskThreshold: parseNumberEnv( + 'COMPLIANCE_RISK_THRESHOLD', + process.env.COMPLIANCE_RISK_THRESHOLD, + DEFAULT_RISK_THRESHOLD, + { min: 0, max: 100 }, + ), + failureMode: parseFailureMode(process.env.COMPLIANCE_FAILURE_MODE), + cacheTtlSeconds: parseNumberEnv( + 'COMPLIANCE_CACHE_TTL_SECONDS', + process.env.COMPLIANCE_CACHE_TTL_SECONDS, + DEFAULT_CACHE_TTL_SECONDS, + { min: 0, max: 30 * 24 * 60 * 60 }, + ), + provider: parseProvider(process.env.COMPLIANCE_PROVIDER), + externalApiTimeoutMs: parseNumberEnv( + 'COMPLIANCE_EXTERNAL_API_TIMEOUT_MS', + process.env.COMPLIANCE_EXTERNAL_API_TIMEOUT_MS, + DEFAULT_EXTERNAL_TIMEOUT_MS, + { min: 100, max: 60_000 }, + ), + allowlist: parseAddressList(process.env.COMPLIANCE_ALLOWLIST), + denylist: parseAddressList(process.env.COMPLIANCE_DENYLIST), + }; + + const externalApiUrl = process.env.COMPLIANCE_EXTERNAL_API_URL; + if (externalApiUrl) { + config.externalApiUrl = externalApiUrl; + } + + const externalApiKey = process.env.COMPLIANCE_EXTERNAL_API_KEY; + if (externalApiKey) { + config.externalApiKey = externalApiKey; + } + + const sanctionsFilePath = process.env.COMPLIANCE_SANCTIONS_FILE; + if (sanctionsFilePath) { + config.sanctionsFilePath = sanctionsFilePath; + } + + return config; +} + +/** Whether the compliance middleware should run at all. */ +export function isComplianceEnforcementEnabled(): boolean { + return getComplianceConfig().enforcementEnabled; +} diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 7832fc29..02e01377 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -72,6 +72,11 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, name: 'Observability', description: 'Prometheus metrics scrape endpoint', }, + { + name: 'Compliance', + description: + 'Sanctions / OFAC screening and SEP-0009 KYC attestation endpoints', + }, ], components: { securitySchemes: { @@ -440,6 +445,41 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, createdAt: { type: 'string', format: 'date-time' }, }, }, + SentinelIncident: { + type: 'object', + required: ['id', 'ruleId', 'severity', 'title', 'description', 'detectedAt', 'threatScore'], + properties: { + id: { type: 'string', format: 'uuid' }, + ruleId: { + type: 'string', + enum: [ + 'VELOCITY_SPIKE', + 'MULTI_STREAM_DRAIN', + 'HIGH_VALUE_DRAIN', + 'TOKEN_VELOCITY_SPIKE', + 'ZERO_RUNWAY_FLOOD', + 'STREAM_CREATION_SPIKE', + ], + }, + severity: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, + title: { type: 'string' }, + description: { type: 'string' }, + address: { type: 'string', nullable: true, description: 'Stellar public key involved' }, + token: { type: 'string', nullable: true }, + streamId: { type: 'string', nullable: true }, + ledger: { type: 'integer', nullable: true }, + detectedAt: { type: 'string', format: 'date-time' }, + threatScore: { type: 'integer', description: 'Per-incident severity weight (10-90)' }, + evidence: { type: 'object', additionalProperties: true }, + circuitBreaker: { + type: 'object', + nullable: true, + description: 'HMAC-signed emergency pause proposal (CRITICAL incidents only)', + additionalProperties: true, + }, + acknowledged: { type: 'boolean' }, + }, + }, HealthResponse: { type: 'object', required: ['status', 'db', 'indexerEnabled', 'uptime', 'checks'], diff --git a/backend/src/controllers/admin.controller.ts b/backend/src/controllers/admin.controller.ts index 709c895f..333f7309 100644 --- a/backend/src/controllers/admin.controller.ts +++ b/backend/src/controllers/admin.controller.ts @@ -9,6 +9,10 @@ import { replayAllDeadLetterEvents, replayDeadLetterEvent, } from '../services/indexerService.js'; +import { + getSentinelService, + isSentinelSeverity, +} from '../services/sentinel.service.js'; import logger from '../logger.js'; /** Parse an optional positive-integer query param. */ @@ -36,6 +40,71 @@ function readSingleQueryParam(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +/** + * GET /api/v1/admin/sentinel/alerts + * + * Sentinel dashboard feed (Issue #1469): current threat score, the addresses + * implicated in recent anomalies, and the incident history itself. Supports + * `severity`, `address` and `limit` filters so on-call tooling can pull just + * the CRITICAL slice during an incident. + */ +export const listSentinelAlertsHandler = async (req: Request, res: Response) => { + const rawSeverity = readSingleQueryParam(req.query.severity)?.toUpperCase(); + const severity = + rawSeverity && isSentinelSeverity(rawSeverity) ? rawSeverity : undefined; + if (rawSeverity && !severity) { + return res.status(400).json({ + error: 'severity must be one of LOW, MEDIUM, HIGH, CRITICAL', + }); + } + + const limit = parseOptionalInt(readSingleQueryParam(req.query.limit)); + const address = readSingleQueryParam(req.query.address); + + try { + const sentinel = getSentinelService(); + const incidents = sentinel.getAlerts({ + ...(severity ? { severity } : {}), + ...(address ? { address } : {}), + ...(limit !== undefined ? { limit } : {}), + }); + + return res.status(200).json({ + threatScore: sentinel.getThreatScore(), + flaggedAddresses: sentinel.getFlaggedAddresses(), + incidents, + count: incidents.length, + generatedAt: new Date().toISOString(), + }); + } catch (err) { + logger.error('[AdminController] Failed to list sentinel alerts:', err); + return res.status(500).json({ error: 'Failed to list sentinel alerts' }); + } +}; + +/** + * POST /api/v1/admin/sentinel/alerts/:id/acknowledge + * + * Marks an incident reviewed so a responding engineer can distinguish the + * anomalies still needing attention from the ones already triaged. + */ +export const acknowledgeSentinelAlertHandler = async ( + req: Request, + res: Response, +) => { + const rawId = req.params.id; + const id = Array.isArray(rawId) ? rawId[0] : rawId; + if (!id) { + return res.status(400).json({ error: 'id is required' }); + } + + const acknowledged = getSentinelService().acknowledgeAlert(id); + if (!acknowledged) { + return res.status(404).json({ error: 'Sentinel incident not found' }); + } + return res.status(200).json({ ok: true, id, acknowledged: true }); +}; + /** * GET /api/v1/admin/indexer/dead-letter * @@ -169,10 +238,7 @@ export const discardDeadLetterHandler = async (req: Request, res: Response) => { const id = Array.isArray(rawId) ? rawId[0] : rawId; if (!id) { return res.status(400).json({ error: 'id is required' }); - } - - const discardedBy = (req as AuthenticatedRequest).user?.publicKey ?? 'unknown'; - + } const discardedBy = (req as AuthenticatedRequest).user?.publicKey ?? 'unknown'; try { const discarded = await discardDeadLetterEvent(id, discardedBy); return res.status(200).json({ ok: true, discarded }); diff --git a/backend/src/lib/metrics.ts b/backend/src/lib/metrics.ts index d2cdaefc..be22f54c 100644 --- a/backend/src/lib/metrics.ts +++ b/backend/src/lib/metrics.ts @@ -268,6 +268,31 @@ export const httpRequestDuration = new Histogram({ registers: [registry], }); +// ─── Sentinel anomaly detection (Issue #1469) ──────────────────────────────── + +/** Anomaly incidents raised by the drain sentinel, by rule and severity. */ +export const sentinelIncidentsTotal = new Counter({ + name: 'flowfi_sentinel_incidents_total', + help: 'Anomaly incidents raised by the stream drain sentinel', + labelNames: ['ruleId', 'severity'] as const, + registers: [registry], +}); + +/** Outbound alert deliveries fanned out by the sentinel, by channel + outcome. */ +export const sentinelAlertsDispatchedTotal = new Counter({ + name: 'flowfi_sentinel_alerts_dispatched_total', + help: 'Sentinel alert deliveries by channel and outcome', + labelNames: ['channel', 'outcome'] as const, + registers: [registry], +}); + +/** Aggregate threat score (0-100) derived from recently retained incidents. */ +export const sentinelThreatScore = new Gauge({ + name: 'flowfi_sentinel_threat_score', + help: 'Aggregate anomaly threat score (0-100) over the retention window', + registers: [registry], +}); + // ─── Helpers ───────────────────────────────────────────────────────────────── /** @@ -313,6 +338,11 @@ export function recordRpcRequest(method: string, seconds: number, outcome: strin rpcRequestsTotal.inc({ method, outcome }); } +/** Publish the latest sentinel threat score for alerting dashboards. */ +export function setSentinelThreatScore(score: number): void { + sentinelThreatScore.set(score); +} + export function getMetricsRegistry(): Registry { return registry; } diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 99b4a552..09becdc1 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -183,6 +183,15 @@ export function getPublisher(): Redis | null { return _publisher; } +/** + * The shared Redis client backing general-purpose data structures (sorted + * sets, counters) as opposed to pub/sub. Returns null when Redis is not + * configured or failed to connect, so callers can fall back gracefully. + */ +export function getRedisClient(): Redis | null { + return _publisher; +} + export function getSubscriber(): Redis | null { return _subscriber; } @@ -238,3 +247,211 @@ export async function disconnectRedis(): Promise { _subscriber = null; _available = false; } + +// --- Sliding-Window Event Tracker (Issue #1469) --- +// +// The stream-drain sentinel needs rolling-window aggregates over +// high-frequency events (withdrawals, stream creations). Redis sorted sets +// give O(log N) inserts plus a range query whose lower bound ages entries out +// via ZREMRANGEBYSCORE. When Redis is not configured (single-instance +// deployments and the unit-test suite) the identical interface is backed by a +// process-local Map so anomaly detection degrades instead of disappearing. +// +// Each sample's numeric weight (the withdrawal volume, or a constant 1 for +// pure counts) is encoded into the sorted-set member as `${id}:${weight}`. +// That lets one key answer both "how many events?" (member count) and +// "how much volume?" (sum of the weights) without a second round-trip. + +export interface SlidingWindowSnapshot { + /** Number of samples currently inside the window. */ + count: number; + /** Sum of the sample weights currently inside the window. */ + sum: number; + /** Epoch milliseconds of the oldest sample, or null when the window is empty. */ + firstAt: number | null; + /** Epoch milliseconds of the newest sample, or null when the window is empty. */ + lastAt: number | null; +} + +export interface SlidingWindowTracker { + /** + * Insert a sample. `id` deduplicates: re-adding the same id refreshes its + * timestamp instead of creating a second entry (used for "distinct streams"). + */ + add(key: string, id: string, weight?: number, at?: number): Promise; + /** Aggregate every sample newer than `now - windowMs`. */ + snapshot(key: string, windowMs: number, now?: number): Promise; + /** Count of distinct ids inside the window. */ + distinct(key: string, windowMs: number, now?: number): Promise; + /** Drop a key entirely (used by tests and admin resets). */ + clear(key: string): Promise; +} + +const EMPTY_SNAPSHOT: SlidingWindowSnapshot = { + count: 0, + sum: 0, + firstAt: null, + lastAt: null, +}; + +function encodeMember(id: string, weight: number): string { + return `${id}:${weight}`; +} + +function decodeWeight(member: string): number { + const separator = member.lastIndexOf(':'); + if (separator === -1) return 1; + const parsed = Number.parseFloat(member.slice(separator + 1)); + return Number.isFinite(parsed) ? parsed : 1; +} + +/** + * In-memory fallback used when Redis is unavailable. Bounded per key so a + * sustained attack cannot grow memory without limit; oldest samples are + * evicted first. + */ +export class InMemorySlidingWindowTracker implements SlidingWindowTracker { + private windows = new Map>(); + private readonly maxSamplesPerKey: number; + + constructor(maxSamplesPerKey = 5_000) { + this.maxSamplesPerKey = maxSamplesPerKey; + } + + async add(key: string, id: string, weight = 1, at = Date.now()): Promise { + let window = this.windows.get(key); + if (!window) { + window = new Map(); + this.windows.set(key, window); + } + const member = encodeMember(id, weight); + window.delete(member); // refresh recency on re-insert + window.set(member, { score: at, weight }); + + // Age out before bounding by size: a sample outside every supported + // window can never be read again, so dropping it is safe. Window + // *reads* must not prune (a narrow read would destroy the data a wider + // baseline read needs). + const retentionCutoff = at - DEFAULT_TRACKER_RETENTION_MS; + for (const [existing, sample] of window) { + if (sample.score < retentionCutoff) window.delete(existing); + } + + while (window.size > this.maxSamplesPerKey) { + const oldest = window.keys().next().value as string | undefined; + if (oldest === undefined) break; + window.delete(oldest); + } + } + + async snapshot( + key: string, + windowMs: number, + now = Date.now(), + ): Promise { + const window = this.windows.get(key); + if (!window || window.size === 0) return { ...EMPTY_SNAPSHOT }; + + const cutoff = now - windowMs; + let count = 0; + let sum = 0; + let firstAt: number | null = null; + let lastAt: number | null = null; + + for (const sample of window.values()) { + if (sample.score < cutoff) continue; + count += 1; + sum += sample.weight; + if (firstAt === null || sample.score < firstAt) firstAt = sample.score; + if (lastAt === null || sample.score > lastAt) lastAt = sample.score; + } + + return { count, sum, firstAt, lastAt }; + } + + async distinct(key: string, windowMs: number, now = Date.now()): Promise { + return (await this.snapshot(key, windowMs, now)).count; + } + + async clear(key: string): Promise { + this.windows.delete(key); + } +} + +/** Redis sorted-set backed tracker. */ +export class RedisSlidingWindowTracker implements SlidingWindowTracker { + constructor(private readonly redis: Redis) {} + + async add(key: string, id: string, weight = 1, at = Date.now()): Promise { + const member = encodeMember(id, weight); + await this.redis.zremrangebyscore(key, '-inf', at - DEFAULT_TRACKER_RETENTION_MS); + await this.redis.zadd(key, at, member); + await this.redis.pexpire(key, DEFAULT_TRACKER_RETENTION_MS); + } + + async snapshot( + key: string, + windowMs: number, + now = Date.now(), + ): Promise { + // Read-only: `add` owns retention pruning. Pruning here would let a + // narrow-window read delete the long-range history a baseline read needs. + const cutoff = now - windowMs; + const raw = (await this.redis.zrangebyscore( + key, + cutoff, + '+inf', + 'WITHSCORES', + )) as string[]; + + let count = 0; + let sum = 0; + let firstAt: number | null = null; + let lastAt: number | null = null; + + // zrangebyscore(...WITHSCORES) returns [member, score, member, score, ...] + for (let i = 0; i < raw.length; i += 2) { + const member = raw[i] ?? ''; + const score = Number.parseFloat(raw[i + 1] ?? ''); + count += 1; + sum += decodeWeight(member); + if (Number.isFinite(score)) { + if (firstAt === null || score < firstAt) firstAt = score; + if (lastAt === null || score > lastAt) lastAt = score; + } + } + + if (count === 0) return { ...EMPTY_SNAPSHOT }; + return { count, sum, firstAt, lastAt }; + } + + async distinct(key: string, windowMs: number, now = Date.now()): Promise { + const cutoff = now - windowMs; + return this.redis.zcount(key, cutoff, '+inf'); + } + + async clear(key: string): Promise { + await this.redis.del(key); + } +} + +/** Samples older than this are pruned on every insert so keys never grow unbounded. */ +const DEFAULT_TRACKER_RETENTION_MS = 24 * 60 * 60 * 1000; + +let _tracker: SlidingWindowTracker | null = null; + +/** + * Resolve the process-wide sliding-window tracker, preferring Redis when a + * client is connected and transparently using the in-memory fallback + * otherwise. + */ +export function getSlidingWindowTracker(): SlidingWindowTracker { + const redis = getRedisClient(); + if (redis) { + return new RedisSlidingWindowTracker(redis); + } + if (!_tracker) { + _tracker = new InMemorySlidingWindowTracker(); + } + return _tracker; +} diff --git a/backend/src/middleware/compliance.middleware.ts b/backend/src/middleware/compliance.middleware.ts new file mode 100644 index 00000000..c952f747 --- /dev/null +++ b/backend/src/middleware/compliance.middleware.ts @@ -0,0 +1,216 @@ +/** + * Compliance screening middleware (Issue #1470) + * + * Intercepts stream funding and withdrawal requests and screens the + * participating wallet addresses before the handler runs. When enforcement is + * enabled and an address is sanctioned (or exceeds the configured risk + * threshold) the request is rejected with a structured 403 and a security audit + * event is persisted. + * + * Enforcement is controlled by `COMPLIANCE_ENFORCEMENT_ENABLED` so local + * development and self-hosted deployments remain fully permissive by default. + */ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import logger from '../logger.js'; +import { getComplianceConfig, type ComplianceConfig } from '../config/compliance.config.js'; +import { + ComplianceScreeningError, + complianceService, + type ComplianceAuditEvent, + type ScreeningResult, +} from '../services/compliance.service.js'; +import type { AuthenticatedRequest } from '../types/auth.types.js'; + +/** Logical action being screened, used for the audit trail. */ +export type ComplianceAction = + | 'stream.create' + | 'stream.deposit' + | 'stream.withdraw' + | 'compliance.screen'; + +/** + * The subset of the compliance service the middleware depends on. Typed as an + * interface so tests can inject a fake without touching the network or DB. + */ +export interface ComplianceScreeningService { + screenAddresses( + addresses: string[], + config?: ComplianceConfig, + ): Promise; + isBlocked(result: ScreeningResult, config: ComplianceConfig): boolean; + writeAuditLog(event: ComplianceAuditEvent): Promise; +} + +export interface ComplianceMiddlewareOptions { + /** Logical action label recorded in audit events. */ + action: ComplianceAction; + /** Override address extraction (mostly for tests / special routes). */ + addressExtractor?: (req: Request) => string[]; + /** Override config resolution (mostly for tests). */ + config?: ComplianceConfig; + /** Override the screening service (mostly for tests). */ + service?: ComplianceScreeningService; +} + +/** + * Default address extraction. Only addresses already present on the request are + * screened; for withdraw/top-up the authenticated wallet is what we know + * synchronously, which is exactly the actor initiating the fund movement. + */ +export function extractComplianceAddresses(req: Request): string[] { + const addresses: string[] = []; + + const push = (value: unknown) => { + if (typeof value === 'string' && value.trim()) { + addresses.push(value.trim()); + } else if (Array.isArray(value)) { + for (const entry of value) { + if (typeof entry === 'string' && entry.trim()) addresses.push(entry.trim()); + } + } + }; + + const user = (req as AuthenticatedRequest).user; + push(user?.publicKey); + + const body = (req.body ?? {}) as Record; + push(body.sender); + push(body.recipient); + push(body.address); + push(body.recipientAddress); + push(body.destination); + push(body.publicKey); + + push((req.params as Record)?.address); + + push((req.query as Record)?.sender); + push((req.query as Record)?.recipient); + push((req.query as Record)?.address); + + return Array.from(new Set(addresses)); +} + +function resolveClientIp(req: Request): string | undefined { + if (typeof req.ip === 'string' && req.ip) return req.ip; + const socket = (req as Request & { socket?: { remoteAddress?: string } }).socket; + return socket?.remoteAddress; +} + +function toAuditEvent( + result: ScreeningResult, + options: { + action: ComplianceAction; + ipAddress?: string; + eventType: string; + metadata?: Record; + }, +): ComplianceAuditEvent { + const event: ComplianceAuditEvent = { + eventType: options.eventType, + action: options.action, + address: result.address, + riskScore: result.riskScore, + tags: result.tags, + isSanctioned: result.isSanctioned, + }; + if (options.ipAddress) event.ipAddress = options.ipAddress; + if (options.metadata) event.metadata = options.metadata; + return event; +} + +/** + * Build the screening middleware for a given action. + */ +export function complianceScreening(options: ComplianceMiddlewareOptions): RequestHandler { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const config = options.config ?? getComplianceConfig(); + + // Disabled by default: preserve local testing and open-source sovereignty. + if (!config.enforcementEnabled) { + next(); + return; + } + + const service = options.service ?? complianceService; + const extractor = options.addressExtractor ?? extractComplianceAddresses; + const addresses = extractor(req); + + if (addresses.length === 0) { + next(); + return; + } + + let results: ScreeningResult[]; + try { + results = await service.screenAddresses(addresses, config); + } catch (error) { + const isScreeningError = error instanceof ComplianceScreeningError; + logger.error( + `[Compliance] Screening failed for ${options.action}: ${(error as Error).message}`, + { action: options.action, screeningError: error instanceof ComplianceScreeningError }, + ); + + // Record the failure so operators can see screening outages in the audit trail. + const screeningErrorIp = resolveClientIp(req); + await service.writeAuditLog({ + eventType: 'SCREENING_ERROR', + action: options.action, + address: addresses.join(','), + ...(screeningErrorIp ? { ipAddress: screeningErrorIp } : {}), + riskScore: 0, + tags: [], + isSanctioned: false, + metadata: { + reason: (error as Error).message, + screeningError: isScreeningError, + }, + }); + + if (config.failureMode === 'fail-closed') { + res.status(503).json({ + error: 'COMPLIANCE_SCREENING_UNAVAILABLE', + message: + 'Compliance screening is temporarily unavailable. Please try again later.', + }); + return; + } + + // fail-open: let the request through, but it has already been audited. + next(); + return; + } + + const blocked = results.filter((result) => service.isBlocked(result, config)); + + if (blocked.length > 0) { + const ipAddress = resolveClientIp(req); + + await Promise.all( + blocked.map((result) => + service.writeAuditLog( + toAuditEvent(result, { + action: options.action, + eventType: 'SCREENING_BLOCKED', + ...(ipAddress ? { ipAddress } : {}), + metadata: { threshold: config.riskThreshold, provider: config.provider }, + }), + ), + ), + ); + + res.status(403).json({ + error: 'COMPLIANCE_RESTRICTION', + message: 'Address restricted under compliance policy', + details: blocked.map((result) => ({ + address: result.address, + riskScore: result.riskScore, + tags: result.tags, + isSanctioned: result.isSanctioned, + })), + }); + return; + } + + next(); + }; +} diff --git a/backend/src/routes/v1/admin.routes.ts b/backend/src/routes/v1/admin.routes.ts index e72c0744..b28581f1 100644 --- a/backend/src/routes/v1/admin.routes.ts +++ b/backend/src/routes/v1/admin.routes.ts @@ -10,8 +10,10 @@ import { previewReplay, } from '../../services/indexerService.js'; import { + acknowledgeSentinelAlertHandler, discardDeadLetterHandler, listDeadLetterHandler, + listSentinelAlertsHandler, replayAllDeadLetterHandler, replayDeadLetterHandler, } from '../../controllers/admin.controller.js'; @@ -634,4 +636,94 @@ router.post('/indexer/dead-letter/:id/replay', replayDeadLetterHandler); */ router.delete('/indexer/dead-letter/:id', discardDeadLetterHandler); +// ─── Sentinel anomaly detection (Issue #1469) ───────────────────────────────── +// +// The real-time drain sentinel records anomalies as the indexer processes +// withdrawals and stream creations. These endpoints are the responder +// interface: current threat score, flagged addresses and incident history. +// `requireAdmin` (applied router-wide above) guards every one of them. + +/** + * @openapi + * /v1/admin/sentinel/alerts: + * get: + * tags: [Admin] + * summary: List sentinel anomalies, threat score, and flagged addresses + * description: | + * Returns the real-time anomaly dashboard backing the drain sentinel: + * the aggregate 0-100 threat score, the addresses implicated in recent + * anomalies, and the incident history (newest first). Filter by + * `severity`, `address`, or page with `limit`. + * security: [{ adminAuth: [] }] + * parameters: + * - in: query + * name: severity + * schema: { type: string, enum: [LOW, MEDIUM, HIGH, CRITICAL] } + * - in: query + * name: address + * schema: { type: string } + * description: Stellar public key to filter incidents by + * - in: query + * name: limit + * schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + * responses: + * 200: + * description: Sentinel dashboard snapshot + * content: + * application/json: + * schema: + * type: object + * properties: + * threatScore: + * type: object + * properties: + * score: { type: integer, example: 75 } + * level: { type: string, enum: [NONE, LOW, MEDIUM, HIGH, CRITICAL] } + * incidentCount: { type: integer } + * bySeverity: { type: object, additionalProperties: { type: integer } } + * windowMinutes: { type: integer } + * flaggedAddresses: + * type: array + * items: + * type: object + * properties: + * address: { type: string } + * threatScore: { type: integer } + * incidentCount: { type: integer } + * highestSeverity: { type: string } + * lastIncidentAt: { type: string, format: date-time } + * incidents: + * type: array + * items: { $ref: '#/components/schemas/SentinelIncident' } + * count: { type: integer } + * generatedAt: { type: string, format: date-time } + * 400: + * description: Invalid severity filter + * 401: + * description: Unauthorized - missing or invalid authentication token + * 403: + * description: Forbidden - admin access required + */ +router.get('/sentinel/alerts', listSentinelAlertsHandler); + +/** + * @openapi + * /v1/admin/sentinel/alerts/{id}/acknowledge: + * post: + * tags: [Admin] + * summary: Acknowledge a sentinel incident + * security: [{ adminAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Incident acknowledged + * 404: + * description: Incident not found + */ +router.post('/sentinel/alerts/:id/acknowledge', acknowledgeSentinelAlertHandler); + export default router; \ No newline at end of file diff --git a/backend/src/routes/v1/compliance.routes.ts b/backend/src/routes/v1/compliance.routes.ts new file mode 100644 index 00000000..267d7e3d --- /dev/null +++ b/backend/src/routes/v1/compliance.routes.ts @@ -0,0 +1,267 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { requireAuth } from '../../middleware/auth.js'; +import { complianceService } from '../../services/compliance.service.js'; +import { getComplianceConfig } from '../../config/compliance.config.js'; +import type { AuthenticatedRequest } from '../../types/auth.types.js'; +import { sendApiError } from '../../types/api-error.js'; +import logger from '../../logger.js'; + +const router = Router(); + +/** + * SEP-0009 (Standard KYC / AML Fields) identity payload. + * + * The canonical field set uses snake_case names as defined by the proposal. + * Unknown vendor extensions are passed through rather than rejected so + * deployments can attach provider-specific metadata. + */ +const sep9FieldsSchema = z + .object({ + first_name: z.string().min(1).optional(), + last_name: z.string().min(1).optional(), + additional_name: z.string().optional(), + address_country_code: z.string().length(2).optional(), + state_or_province: z.string().optional(), + city: z.string().optional(), + postal_code: z.string().optional(), + address: z.string().optional(), + mobile_number: z.string().optional(), + email_address: z.string().email().optional(), + birth_date: z.string().optional(), + birth_place: z.string().optional(), + birth_country_code: z.string().length(2).optional(), + tax_id: z.string().optional(), + tax_id_name: z.string().optional(), + occupation: z.string().optional(), + employer_name: z.string().optional(), + employer_address: z.string().optional(), + language_code: z.string().optional(), + id_type: z.string().optional(), + id_number: z.string().optional(), + id_issuing_country_code: z.string().length(2).optional(), + id_issue_date: z.string().optional(), + id_expiration_date: z.string().optional(), + }) + .passthrough() + .refine((fields) => Object.keys(fields).length > 0, { + message: 'At least one SEP-0009 identity field must be provided', + }); + +const kycAttestationSchema = z.object({ + subjectAddress: z.string().min(1, 'subjectAddress is required'), + organization: z.string().min(1).optional(), + proof: z.string().min(1, 'proof is required'), + proofType: z.enum(['ed25519', 'secp256k1', 'stellar-signature']).optional(), + fields: sep9FieldsSchema, +}); + +const screenBodySchema = z.object({ + address: z.string().min(1, 'address is required'), +}); + +/** + * @openapi + * /v1/compliance/kyc-attestation: + * post: + * tags: + * - Compliance + * summary: Submit a SEP-0009 KYC/AML attestation + * description: | + * Allows an organization to submit a cryptographic proof of identity + * verification for a wallet, following the SEP-0009 (Standard KYC / AML + * Fields) schema. Attestations are stored as PENDING for out-of-band + * review and are recorded in the compliance audit trail. + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [subjectAddress, proof, fields] + * properties: + * subjectAddress: + * type: string + * description: Stellar public key the identity belongs to + * organization: + * type: string + * description: Organization identifier; defaults to the authenticated wallet + * proof: + * type: string + * description: Cryptographic proof / signature over the identity payload + * proofType: + * type: string + * enum: [ed25519, secp256k1, stellar-signature] + * fields: + * type: object + * description: SEP-0009 KYC/AML fields (snake_case, vendor fields passthrough) + * responses: + * 201: + * description: Attestation accepted for review + * 400: + * description: Invalid attestation payload + * 401: + * description: Unauthorized - missing or invalid authentication + * 500: + * description: Internal server error + */ +router.post('/kyc-attestation', requireAuth, async (req, res) => { + try { + const parsed = kycAttestationSchema.safeParse(req.body); + if (!parsed.success) { + const message = parsed.error.issues + .map((issue) => `${issue.path.join('.') || 'request'}: ${issue.message}`) + .join('; '); + return sendApiError(res, 400, 'VALIDATION_ERROR', message, parsed.error.issues); + } + + const organization = + parsed.data.organization ?? (req as AuthenticatedRequest).user.publicKey; + + const attestation = await complianceService.submitKycAttestation({ + organization, + subjectAddress: parsed.data.subjectAddress, + sep9Fields: parsed.data.fields, + proof: parsed.data.proof, + ...(parsed.data.proofType ? { proofType: parsed.data.proofType } : {}), + }); + + return res.status(201).json({ + success: true, + attestation: { + id: attestation.id, + organization: attestation.organization, + subjectAddress: attestation.subjectAddress, + status: attestation.status, + createdAt: attestation.createdAt, + }, + }); + } catch (error) { + logger.error('[Compliance] Error submitting KYC attestation:', error); + return sendApiError( + res, + 500, + 'INTERNAL_SERVER_ERROR', + 'A technical error occurred. Please try again later.', + ); + } +}); + +/** + * @openapi + * /v1/compliance/screen: + * post: + * tags: + * - Compliance + * summary: Screen an address against sanctions data + * description: | + * Runs the configured sanctions / risk screening provider for a wallet + * address and returns the result. Results are cached for the configured + * TTL. Requires authentication to prevent open enumeration. + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [address] + * properties: + * address: + * type: string + * description: Stellar public key to screen + * responses: + * 200: + * description: Screening result + * 400: + * description: Invalid request body + * 401: + * description: Unauthorized - missing or invalid authentication + * 503: + * description: Screening provider unavailable + */ +router.post('/screen', requireAuth, async (req, res) => { + const parsed = screenBodySchema.safeParse(req.body); + if (!parsed.success) { + return sendApiError(res, 400, 'VALIDATION_ERROR', 'address is required'); + } + + const config = getComplianceConfig(); + try { + const result = await complianceService.screenAddress(parsed.data.address, config); + return res.status(200).json({ + ...result, + sanctioned: result.isSanctioned, + blocked: complianceService.isBlocked(result, config), + threshold: config.riskThreshold, + }); + } catch (error) { + logger.error('[Compliance] Error screening address:', error); + return sendApiError( + res, + 503, + 'COMPLIANCE_SCREENING_UNAVAILABLE', + 'Compliance screening is temporarily unavailable. Please try again later.', + ); + } +}); + +/** + * @openapi + * /v1/compliance/screen/{address}: + * get: + * tags: + * - Compliance + * summary: Screen a wallet address (read-only) + * description: Convenience GET variant of /v1/compliance/screen for audit tooling. + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: address + * required: true + * schema: { type: string } + * description: Stellar public key to screen + * responses: + * 200: + * description: Screening result + * 400: + * description: Address is required + * 401: + * description: Unauthorized - missing or invalid authentication + * 503: + * description: Screening provider unavailable + */ +router.get('/screen/:address', requireAuth, async (req, res) => { + const address = Array.isArray(req.params.address) + ? req.params.address[0] + : (req.params.address ?? '').trim(); + + if (!address) { + return sendApiError(res, 400, 'INVALID_ADDRESS', 'Address is required'); + } + + const config = getComplianceConfig(); + try { + const result = await complianceService.screenAddress(address, config); + return res.status(200).json({ + ...result, + sanctioned: result.isSanctioned, + blocked: complianceService.isBlocked(result, config), + threshold: config.riskThreshold, + }); + } catch (error) { + logger.error('[Compliance] Error screening address:', error); + return sendApiError( + res, + 503, + 'COMPLIANCE_SCREENING_UNAVAILABLE', + 'Compliance screening is temporarily unavailable. Please try again later.', + ); + } +}); + +export default router; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 1d0cd267..4625921e 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -5,6 +5,7 @@ import userRoutes from "./user.routes.js"; import authRoutes from "./auth.routes.js"; import adminRoutes from "./admin.routes.js"; import webhookRoutes from "./webhook.routes.js"; +import complianceRoutes from "./compliance.routes.js"; import analyticsRoutes from "./analytics.routes.js"; import tokenRoutes from "./token.routes.js"; @@ -16,6 +17,7 @@ router.use("/events", eventsRoutes); router.use("/users", userRoutes); router.use("/auth", authRoutes); router.use("/webhooks", webhookRoutes); +router.use("/compliance", complianceRoutes); router.use("/analytics", analyticsRoutes); router.use("/tokens", tokenRoutes); diff --git a/backend/src/routes/v1/stream.routes.ts b/backend/src/routes/v1/stream.routes.ts index ef423f8e..ef4522fe 100644 --- a/backend/src/routes/v1/stream.routes.ts +++ b/backend/src/routes/v1/stream.routes.ts @@ -15,6 +15,7 @@ import { simulateStreamHandler } from '../../controllers/stream/simulate.js'; import { withdrawHandler } from './streams/withdraw.js'; import { requireAuth } from '../../middleware/auth.js'; import { streamCreationRateLimiter } from '../../middleware/stream-rate-limiter.middleware.js'; +import { complianceScreening } from '../../middleware/compliance.middleware.js'; const router = Router(); @@ -102,7 +103,13 @@ const router = Router(); * schema: * $ref: '#/components/schemas/Error' */ -router.post('/', requireAuth, streamCreationRateLimiter, createStream); +router.post( + '/', + requireAuth, + streamCreationRateLimiter, + complianceScreening({ action: 'stream.create' }), + createStream, +); /** * @openapi @@ -695,7 +702,12 @@ router.post('/:streamId/resume', requireAuth, resumeStream); * schema: * $ref: '#/components/schemas/Error' */ -router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any); +router.post( + '/:streamId/withdraw', + requireAuth, + complianceScreening({ action: 'stream.withdraw' }), + withdrawHandler as any, +); /** * @openapi @@ -771,7 +783,12 @@ router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any); * schema: * $ref: '#/components/schemas/Error' */ -router.post('/:streamId/top-up', requireAuth, topUpStreamHandler); +router.post( + '/:streamId/top-up', + requireAuth, + complianceScreening({ action: 'stream.deposit' }), + topUpStreamHandler, +); /** * @openapi diff --git a/backend/src/services/compliance.service.ts b/backend/src/services/compliance.service.ts new file mode 100644 index 00000000..e3fcc71e --- /dev/null +++ b/backend/src/services/compliance.service.ts @@ -0,0 +1,447 @@ +/** + * Compliance / sanctions screening service (Issue #1470) + * + * Screens Stellar wallet addresses against sanctions data before funds move and + * records an audit trail of every decision. Two provider modes are supported: + * + * - `local` : in-process denylist/allowlist plus an optional JSON sanctions + * feed (e.g. a periodically refreshed OFAC SDN export). + * - `external` : an external screening provider (Chainalysis, TRM Labs, + * Elliptic, …) reached over HTTPS. + * + * Results are cached with a configurable TTL (Redis when available, otherwise + * the bounded in-process `MemoryCache`) so repeated screens of the same address + * do not re-hit an external provider. + */ +import { readFileSync } from 'node:fs'; +import logger from '../logger.js'; +import { cache, getPublisher, isRedisAvailable } from '../lib/redis.js'; +import { prisma } from '../lib/prisma.js'; +import { + getComplianceConfig, + type ComplianceConfig, +} from '../config/compliance.config.js'; + +/** + * Result of screening a single address. + */ +export interface ScreeningResult { + address: string; + isSanctioned: boolean; + riskScore: number; // 0 (clean) to 100 (blocked) + tags: string[]; // e.g. ["OFAC", "Darknet", "Ransomware"] + screenedAt: Date; + cached: boolean; +} + +/** Provider output before address/timestamp/cache fields are attached. */ +type ProviderResult = Omit; + +/** Thrown when a screening provider cannot produce a trustworthy answer. */ +export class ComplianceScreeningError extends Error { + constructor(message: string) { + super(message); + this.name = 'ComplianceScreeningError'; + } +} + +/** Audit event persisted for blocked / failed screening and KYC submissions. */ +export interface ComplianceAuditEvent { + eventType: string; + address: string; + ipAddress?: string; + riskScore: number; + tags: string[]; + isSanctioned: boolean; + action?: string; + metadata?: Record; +} + +const CACHE_KEY_PREFIX = 'compliance:screening:'; + +/** + * Cache key for a screening result. The provider is part of the key so that + * switching between `local` and `external` never serves a stale result. + */ +function screeningCacheKey(address: string, config: ComplianceConfig): string { + return `${CACHE_KEY_PREFIX}${config.provider}:${address}`; +} + +/** Lazy-loaded sanctions feeds keyed by file path. */ +const sanctionsFileCache = new Map>(); + +/** Parsed allow/deny lookups keyed by a config fingerprint (avoids re-parsing). */ +let denylistIndex: { fingerprint: string; entries: Map } | null = null; + +function configFingerprint(config: ComplianceConfig): string { + return `${config.denylist.join(',')}|${config.sanctionsFilePath ?? ''}`; +} + +/** + * Load an optional JSON sanctions feed. Tolerant of the shapes commonly + * exported by sanctions vendors: + * + * - `["GABC...", "GDEF..."]` + * - `[{ "address": "GABC...", "tags": ["OFAC"], "program": "SDN" }]` + * - `{ "addresses": ["GABC..."] }` + * - `{ "GABC...": ["OFAC"] }` + */ +function loadSanctionsFile(filePath: string): Map { + const cached = sanctionsFileCache.get(filePath); + if (cached) { + return cached; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw new ComplianceScreeningError( + `Unable to read sanctions feed '${filePath}': ${(error as Error).message}`, + ); + } + + const entries = new Map(); + + const addEntry = (address: unknown, tags: string[]) => { + if (typeof address === 'string' && address.trim()) { + entries.set(address.trim(), tags.length > 0 ? tags : ['OFAC']); + } + }; + + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (typeof item === 'string') { + addEntry(item, ['OFAC']); + } else if (item && typeof item === 'object') { + const record = item as Record; + const tags = Array.isArray(record.tags) + ? record.tags.filter((tag): tag is string => typeof tag === 'string') + : []; + if (typeof record.program === 'string') tags.push(record.program); + addEntry(record.address, tags); + } + } + } else if (parsed && typeof parsed === 'object') { + const record = parsed as Record; + if (Array.isArray(record.addresses)) { + for (const address of record.addresses) addEntry(address, ['OFAC']); + } else { + for (const [address, value] of Object.entries(record)) { + const tags = Array.isArray(value) + ? value.filter((tag): tag is string => typeof tag === 'string') + : []; + addEntry(address, tags); + } + } + } + + sanctionsFileCache.set(filePath, entries); + return entries; +} + +/** Build (and cache) the combined denylist lookup for the given config. */ +function getDenylistIndex(config: ComplianceConfig): Map { + const fingerprint = configFingerprint(config); + if (denylistIndex && denylistIndex.fingerprint === fingerprint) { + return denylistIndex.entries; + } + + const entries = new Map(); + if (config.sanctionsFilePath) { + for (const [address, tags] of loadSanctionsFile(config.sanctionsFilePath)) { + entries.set(address, tags); + } + } + for (const address of config.denylist) { + entries.set(address, ['OFAC']); + } + + denylistIndex = { fingerprint, entries }; + return entries; +} + +/** + * Local provider: allowlist wins, then the denylist/sanctions feed, otherwise + * the address is considered clean. + */ +function localScreen(address: string, config: ComplianceConfig): ProviderResult { + const normalized = address.trim(); + + if (config.allowlist.includes(normalized)) { + return { isSanctioned: false, riskScore: 0, tags: ['ALLOWLIST'] }; + } + + const tags = getDenylistIndex(config).get(normalized); + if (tags) { + return { isSanctioned: true, riskScore: 100, tags }; + } + + return { isSanctioned: false, riskScore: 0, tags: [] }; +} + +function clampRiskScore(value: unknown, fallback: number): number { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(100, Math.max(0, Math.round(parsed))); +} + +function normalizeExternalPayload(payload: unknown): ProviderResult { + const record = + payload && typeof payload === 'object' + ? ((payload as Record).data as Record) ?? + (payload as Record) + : {}; + + const isSanctioned = record.isSanctioned === true; + const riskScore = clampRiskScore(record.riskScore, isSanctioned ? 100 : 0); + const tags = Array.isArray(record.tags) + ? record.tags.filter((tag): tag is string => typeof tag === 'string') + : []; + + return { isSanctioned, riskScore, tags }; +} + +/** + * External provider: POST `{ address }` to the configured endpoint, forwarding + * the API key as a bearer token, with an abort-based timeout. + */ +async function externalScreen( + address: string, + config: ComplianceConfig, +): Promise { + if (!config.externalApiUrl) { + throw new ComplianceScreeningError( + 'COMPLIANCE_EXTERNAL_API_URL is not configured for the external provider.', + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.externalApiTimeoutMs); + + try { + const headers: Record = { 'content-type': 'application/json' }; + if (config.externalApiKey) { + headers.authorization = `Bearer ${config.externalApiKey}`; + } + + const response = await fetch(config.externalApiUrl, { + method: 'POST', + headers, + body: JSON.stringify({ address }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new ComplianceScreeningError( + `External screening provider returned HTTP ${response.status}.`, + ); + } + + const payload: unknown = await response.json(); + return normalizeExternalPayload(payload); + } catch (error) { + if (error instanceof ComplianceScreeningError) throw error; + throw new ComplianceScreeningError( + `External screening provider request failed: ${(error as Error).message}`, + ); + } finally { + clearTimeout(timeout); + } +} + +/** Screening cache TTL in seconds for the active config. */ +function resolveTtlSeconds(config: ComplianceConfig): number { + return config.cacheTtlSeconds; +} + +async function readCachedResult( + address: string, + config: ComplianceConfig, +): Promise { + const key = screeningCacheKey(address, config); + + if (isRedisAvailable()) { + const client = getPublisher(); + if (client) { + try { + const raw = await client.get(key); + if (raw) { + return { ...(JSON.parse(raw) as ScreeningResult), cached: true }; + } + return null; + } catch (error) { + logger.warn(`[Compliance] Redis cache read failed: ${(error as Error).message}`); + } + } + } + + const cached = cache.get(key); + if (cached) { + return { ...cached, cached: true }; + } + return null; +} + +async function writeCachedResult( + address: string, + result: ScreeningResult, + config: ComplianceConfig, +): Promise { + const key = screeningCacheKey(address, config); + const ttl = resolveTtlSeconds(config); + + // Never serve a stale `cached: true` flag to a fresh caller. + const stored: ScreeningResult = { ...result, cached: false }; + + if (isRedisAvailable()) { + const client = getPublisher(); + if (client) { + try { + await client.set(key, JSON.stringify(stored), 'EX', ttl); + return; + } catch (error) { + logger.warn(`[Compliance] Redis cache write failed: ${(error as Error).message}`); + } + } + } + + cache.set(key, stored, ttl); +} + +/** + * Compliance service: screening, risk evaluation and audit persistence. + */ +export class ComplianceService { + /** + * Screen a single address, using the cache when possible. + * + * Throws {@link ComplianceScreeningError} when the provider cannot produce a + * trustworthy answer; callers decide how to apply the configured failure mode. + */ + async screenAddress( + address: string, + config: ComplianceConfig = getComplianceConfig(), + ): Promise { + const normalized = address.trim(); + + const cached = await readCachedResult(normalized, config); + if (cached) { + return cached; + } + + const providerResult = + config.provider === 'external' + ? await externalScreen(normalized, config) + : localScreen(normalized, config); + + const result: ScreeningResult = { + address: normalized, + isSanctioned: providerResult.isSanctioned, + riskScore: providerResult.riskScore, + tags: providerResult.tags, + screenedAt: new Date(), + cached: false, + }; + + await writeCachedResult(normalized, result, config); + return result; + } + + /** Screen many addresses in parallel, deduplicating input. */ + async screenAddresses( + addresses: string[], + config: ComplianceConfig = getComplianceConfig(), + ): Promise { + const unique = Array.from(new Set(addresses.map((a) => a.trim()).filter(Boolean))); + return Promise.all(unique.map((address) => this.screenAddress(address, config))); + } + + /** + * A result is blocked when the address is explicitly sanctioned or its risk + * score exceeds the configured threshold. + */ + isBlocked(result: ScreeningResult, config: ComplianceConfig): boolean { + return result.isSanctioned || result.riskScore > config.riskThreshold; + } + + /** + * Persist an audit event and emit a structured log line. + * + * Audit logging must never break the request path: database failures are + * downgraded to a warning and the structured log is always emitted. + */ + async writeAuditLog(event: ComplianceAuditEvent): Promise { + logger.info('[Compliance][audit]', { + audit: true, + eventType: event.eventType, + action: event.action, + address: event.address, + ipAddress: event.ipAddress, + riskScore: event.riskScore, + tags: event.tags, + isSanctioned: event.isSanctioned, + }); + + try { + await prisma.complianceAuditLog.create({ + data: { + eventType: event.eventType, + action: event.action ?? null, + address: event.address, + ipAddress: event.ipAddress ?? null, + riskScore: event.riskScore, + tags: event.tags, + isSanctioned: event.isSanctioned, + metadata: event.metadata ? JSON.stringify(event.metadata) : null, + }, + }); + } catch (error) { + logger.warn( + `[Compliance] Failed to persist audit log for ${event.address}: ${(error as Error).message}`, + ); + } + } + + /** + * Store a SEP-0009 KYC attestation submitted by an organization. + */ + async submitKycAttestation(input: { + organization: string; + subjectAddress: string; + sep9Fields: Record; + proof: string; + proofType?: string; + }) { + const attestation = await prisma.kycAttestation.create({ + data: { + organization: input.organization, + subjectAddress: input.subjectAddress, + sep9Fields: JSON.stringify(input.sep9Fields), + proof: input.proof, + proofType: input.proofType ?? null, + status: 'PENDING', + }, + }); + + await this.writeAuditLog({ + eventType: 'KYC_ATTESTATION_SUBMITTED', + address: input.subjectAddress, + riskScore: 0, + tags: [], + isSanctioned: false, + action: 'compliance.kyc_attestation', + metadata: { organization: input.organization, attestationId: attestation.id }, + }); + + return attestation; + } + + /** Test helper: drop the in-process screening cache and parsed sanctions feeds. */ + clearScreeningCache(): void { + sanctionsFileCache.clear(); + denylistIndex = null; + } +} + +export const complianceService = new ComplianceService(); diff --git a/backend/src/services/sentinel.service.ts b/backend/src/services/sentinel.service.ts new file mode 100644 index 00000000..41d7e636 --- /dev/null +++ b/backend/src/services/sentinel.service.ts @@ -0,0 +1,1117 @@ +/** + * Real-Time Stream Anomaly Detection & High-Velocity Drain Sentinel (Issue #1469) + * + * Decentralized payment streaming is exposed to private-key compromise, rogue + * batch drains and fee diversion: by the time a human notices anomalous + * activity in a log line, a compromised payroll wallet can have streamed out + * millions. This service watches stream lifecycle events as they are indexed + * and raises severity-graded incidents when their velocity or shape crosses + * learned/heuristic thresholds. + * + * Design + * ------ + * - **Velocity trackers** are sliding windows over Redis sorted sets (see + * `getSlidingWindowTracker` in `lib/redis.ts`), with a transparent in-memory + * fallback so detection still works on single-instance deployments. + * - **Heuristics** are pure functions of the window aggregates, so they are + * cheap to run inline on the indexer hot path and trivially unit-testable. + * - **Alerting** fans out through a unified adapter to Slack, Discord and + * PagerDuty. Every incident is retained in a bounded in-process history that + * the admin API exposes. + * - **Circuit breaker** — CRITICAL incidents additionally produce an + * HMAC-signed `set_emergency_pause(true)` proposal so multisig signers can + * act without hand-authoring the payload during an incident. + */ + +import crypto from 'crypto'; +import logger from '../logger.js'; +import { + getSlidingWindowTracker, + type SlidingWindowTracker, + type SlidingWindowSnapshot, +} from '../lib/redis.js'; +import { + sentinelAlertsDispatchedTotal, + sentinelIncidentsTotal, + setSentinelThreatScore, +} from '../lib/metrics.js'; + +// ─── Severity ──────────────────────────────────────────────────────────────── + +export const SENTINEL_SEVERITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] as const; +export type SentinelSeverity = (typeof SENTINEL_SEVERITIES)[number]; + +const SEVERITY_WEIGHT: Record = { + LOW: 10, + MEDIUM: 25, + HIGH: 50, + CRITICAL: 90, +}; + +function higherSeverity( + a: SentinelSeverity, + b: SentinelSeverity, +): SentinelSeverity { + return SEVERITY_WEIGHT[a] >= SEVERITY_WEIGHT[b] ? a : b; +} + +export function isSentinelSeverity(value: unknown): value is SentinelSeverity { + return ( + typeof value === 'string' && + (SENTINEL_SEVERITIES as readonly string[]).includes(value) + ); +} + +// ─── Rules ─────────────────────────────────────────────────────────────────── + +export const SENTINEL_RULES = { + /** A wallet's withdrawal volume in one minute dwarfs its 24h baseline. */ + VELOCITY_SPIKE: 'VELOCITY_SPIKE', + /** One address withdrawing across an unusual number of distinct streams. */ + MULTI_STREAM_DRAIN: 'MULTI_STREAM_DRAIN', + /** A single withdrawal that is huge in absolute or relative terms. */ + HIGH_VALUE_DRAIN: 'HIGH_VALUE_DRAIN', + /** The same as VELOCITY_SPIKE but scoped to one token across all wallets. */ + TOKEN_VELOCITY_SPIKE: 'TOKEN_VELOCITY_SPIKE', + /** A flood of near-zero-runway streams, the classic dust-drain shape. */ + ZERO_RUNWAY_FLOOD: 'ZERO_RUNWAY_FLOOD', + /** Abrupt burst of stream creations for a single token. */ + STREAM_CREATION_SPIKE: 'STREAM_CREATION_SPIKE', +} as const; + +export type SentinelRuleId = + (typeof SENTINEL_RULES)[keyof typeof SENTINEL_RULES]; + +// ─── Config ────────────────────────────────────────────────────────────────── + +export interface SentinelConfig { + enabled: boolean; + /** Rolling window for per-minute velocity comparisons (default 60s). */ + velocityWindowMs: number; + /** Baseline window a spike is measured against (default 24h). */ + baselineWindowMs: number; + /** + * A spike is declared when the current window is at least this many times + * the baseline average. 4 == a 300% increase. + */ + velocitySpikeMultiplier: number; + /** Minimum withdrawals in the window before a velocity spike can fire. */ + velocityMinEvents: number; + /** Distinct streams one address may drain inside the ledger window. */ + multiStreamMaxStreams: number; + /** Ledgers the multi-stream drain window spans (default 3). */ + multiStreamWindowLedgers: number; + /** Single-withdrawal notional (USD) that is always suspicious. */ + highValueThresholdUsd: number; + /** Share of a stream's deposit a single withdrawal may consume (%). */ + balanceDrainPct: number; + /** Stream creations per token inside `creationSpikeWindowMs`. */ + creationSpikeWindowMs: number; + creationSpikeThreshold: number; + /** Streams with a runway shorter than this count toward the flood. */ + zeroRunwayMaxSeconds: number; + zeroRunwayWindowMs: number; + zeroRunwayFloodThreshold: number; + /** Suppress repeat alerts for the same rule+subject within this window. */ + alertCooldownMs: number; + /** Incidents older than this are pruned from history. */ + retentionMs: number; + /** Trailing window the aggregate threat score is computed over. */ + threatWindowMs: number; + slackWebhookUrl?: string; + discordWebhookUrl?: string; + pagerDutyRoutingKey?: string; + /** Secret used to HMAC-sign emergency pause proposals. */ + proposalSecret: string; +} + +const LEDGER_MS = 5_000; // Stellar targets ~5s ledgers + +function readNumberEnv(name: string, fallback: number): number { + const parsed = Number.parseFloat(process.env[name] ?? ''); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function readBooleanEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined) return fallback; + return raw === 'true' || raw === '1'; +} + +function readOptionalEnv(name: string): string | undefined { + const raw = process.env[name]?.trim(); + return raw ? raw : undefined; +} + +export function getSentinelConfig(): SentinelConfig { + const slackWebhookUrl = readOptionalEnv('SENTINEL_SLACK_WEBHOOK_URL'); + const discordWebhookUrl = readOptionalEnv('SENTINEL_DISCORD_WEBHOOK_URL'); + const pagerDutyRoutingKey = readOptionalEnv('SENTINEL_PAGERDUTY_ROUTING_KEY'); + + return { + enabled: readBooleanEnv('SENTINEL_ENABLED', true), + velocityWindowMs: readNumberEnv('SENTINEL_VELOCITY_WINDOW_MS', 60_000), + baselineWindowMs: readNumberEnv( + 'SENTINEL_BASELINE_WINDOW_MS', + 24 * 60 * 60 * 1000, + ), + velocitySpikeMultiplier: readNumberEnv( + 'SENTINEL_VELOCITY_SPIKE_MULTIPLIER', + 4, + ), + velocityMinEvents: readNumberEnv('SENTINEL_VELOCITY_MIN_EVENTS', 5), + multiStreamMaxStreams: readNumberEnv('SENTINEL_MULTI_STREAM_MAX', 20), + multiStreamWindowLedgers: readNumberEnv( + 'SENTINEL_MULTI_STREAM_WINDOW_LEDGERS', + 3, + ), + highValueThresholdUsd: readNumberEnv( + 'SENTINEL_HIGH_VALUE_THRESHOLD_USD', + 100_000, + ), + balanceDrainPct: readNumberEnv('SENTINEL_BALANCE_DRAIN_PCT', 80), + creationSpikeWindowMs: readNumberEnv( + 'SENTINEL_CREATION_SPIKE_WINDOW_MS', + 5 * 60 * 1000, + ), + creationSpikeThreshold: readNumberEnv( + 'SENTINEL_CREATION_SPIKE_THRESHOLD', + 25, + ), + zeroRunwayMaxSeconds: readNumberEnv('SENTINEL_ZERO_RUNWAY_MAX_SECONDS', 60), + zeroRunwayWindowMs: readNumberEnv( + 'SENTINEL_ZERO_RUNWAY_WINDOW_MS', + 5 * 60 * 1000, + ), + zeroRunwayFloodThreshold: readNumberEnv( + 'SENTINEL_ZERO_RUNWAY_FLOOD_THRESHOLD', + 50, + ), + alertCooldownMs: readNumberEnv('SENTINEL_ALERT_COOLDOWN_MS', 60_000), + retentionMs: readNumberEnv( + 'SENTINEL_RETENTION_MS', + 24 * 60 * 60 * 1000, + ), + threatWindowMs: readNumberEnv('SENTINEL_THREAT_WINDOW_MS', 15 * 60 * 1000), + ...(slackWebhookUrl ? { slackWebhookUrl } : {}), + ...(discordWebhookUrl ? { discordWebhookUrl } : {}), + ...(pagerDutyRoutingKey ? { pagerDutyRoutingKey } : {}), + proposalSecret: + readOptionalEnv('SENTINEL_PROPOSAL_SECRET') ?? + readOptionalEnv('JWT_SECRET') ?? + 'flowfi-sentinel-dev-secret', + }; +} + +// ─── Incidents ─────────────────────────────────────────────────────────────── + +/** + * HMAC-signed request for the on-chain emergency pause. Signing proves the + * proposal was generated by this service (and was not tampered with in transit + * to the multisig signers' tooling); it is deliberately *not* a transaction + * signature, so a leaked sentinel secret cannot move funds. + */ +export interface EmergencyPauseProposal { + action: 'set_emergency_pause'; + value: true; + incidentId: string; + reason: string; + requestedAt: string; + payloadHash: string; + signature: string; + algorithm: 'hmac-sha256'; +} + +export interface SentinelIncident { + id: string; + ruleId: SentinelRuleId; + severity: SentinelSeverity; + title: string; + description: string; + address: string | null; + token: string | null; + streamId: string | null; + ledger: number | null; + detectedAt: string; + threatScore: number; + evidence: Record; + /** Present only for CRITICAL incidents. */ + circuitBreaker: EmergencyPauseProposal | null; + /** True while the incident has not been reviewed via the admin API. */ + acknowledged: boolean; +} + +export interface SentinelAlertDelivery { + channel: 'slack' | 'discord' | 'pagerduty'; + ok: boolean; + status?: number; + error?: string; +} + +export interface SentinelThreatSummary { + score: number; + level: SentinelSeverity | 'NONE'; + incidentCount: number; + bySeverity: Record; + windowMinutes: number; + generatedAt: string; +} + +export interface SentinelFlaggedAddress { + address: string; + threatScore: number; + incidentCount: number; + highestSeverity: SentinelSeverity; + lastIncidentAt: string; +} + +// ─── Inputs ────────────────────────────────────────────────────────────────── + +export interface WithdrawalAnomalyInput { + address: string; + token: string; + /** Raw i128 withdrawal amount as a string. */ + amount: string; + /** USD valuation, when a price source is available. */ + amountUsd?: number; + streamId: string; + ledger: number; + txHash: string; + /** Total deposited into the stream the withdrawal came from. */ + streamDeposited?: string; + now?: number; +} + +export interface StreamCreationAnomalyInput { + sender: string; + token: string; + streamId: string; + /** Seconds of funding the stream has before it depletes. */ + runwaySeconds: number; + ledger: number; + now?: number; +} + +export interface SentinelServiceDeps { + tracker?: SlidingWindowTracker; + fetchImpl?: typeof fetch; + now?: () => number; +} + +const MAX_INCIDENTS = 500; + +function toNumber(value: string | number | undefined): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value !== 'string') return 0; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function truncate(value: string, max = 120): string { + return value.length <= max ? value : `${value.slice(0, max)}…`; +} + +/** + * Sliding-window anomaly detector and alert dispatcher. + * + * Constructed with an explicit config + injected dependencies so the whole + * thing is deterministic under test; production code uses the `sentinelService` + * singleton below. + */ +export class SentinelService { + private readonly config: SentinelConfig; + private readonly tracker: SlidingWindowTracker; + private readonly fetchImpl: typeof fetch; + private readonly now: () => number; + private readonly incidents: SentinelIncident[] = []; + private readonly cooldowns = new Map(); + /** Every tracker key touched this process, so `reset()` can clear them all. */ + private readonly trackedKeys = new Set(); + + constructor( + config: SentinelConfig = getSentinelConfig(), + deps: SentinelServiceDeps = {}, + ) { + this.config = config; + this.tracker = deps.tracker ?? getSlidingWindowTracker(); + this.fetchImpl = deps.fetchImpl ?? globalThis.fetch; + this.now = deps.now ?? (() => Date.now()); + } + + private registerKey(key: string): string { + this.trackedKeys.add(key); + return key; + } + + // ── Observation entry points ──────────────────────────────────────────── + + /** + * Observe a withdrawal and evaluate every velocity/drain heuristic against + * the freshly updated windows. Returns the incidents raised by *this* event + * (an empty array when the withdrawal looks normal or the alert is cooling + * down). + */ + async recordWithdrawal( + input: WithdrawalAnomalyInput, + ): Promise { + if (!this.config.enabled) return []; + const now = input.now ?? this.now(); + const amount = toNumber(input.amount); + const notional = input.amountUsd ?? null; + const weight = notional ?? amount; + const detected: SentinelIncident[] = []; + + // 1) Per-wallet withdrawal velocity (count + volume). + const walletKey = this.registerKey(`wallet_withdrawal_rate:${input.address}`); + await this.tracker.add(walletKey, input.txHash, weight, now); + const window = await this.tracker.snapshot( + walletKey, + this.config.velocityWindowMs, + now, + ); + const baseline = await this.tracker.snapshot( + walletKey, + this.config.baselineWindowMs, + now, + ); + const velocity = this.evaluateVelocity( + window, + baseline, + this.config.velocityMinEvents, + ); + if (velocity) { + detected.push( + this.makeIncident({ + ruleId: SENTINEL_RULES.VELOCITY_SPIKE, + severity: velocity.severity, + title: 'Withdrawal velocity spike', + description: + `Wallet ${truncate(input.address)} withdrew ${window.count} time(s) in ` + + `${Math.round(this.config.velocityWindowMs / 1000)}s — ` + + `${velocity.ratioLabel} the trailing baseline.`, + address: input.address, + token: input.token, + streamId: input.streamId, + ledger: input.ledger, + now, + evidence: { + windowCount: window.count, + windowVolume: window.sum, + baselineVolumePerWindow: velocity.baselineAvg, + increaseRatio: velocity.ratio, + windowMs: this.config.velocityWindowMs, + txHash: input.txHash, + }, + }), + ); + } + + // 2) Rapid multi-stream drain by a single address. + const streamKey = this.registerKey(`wallet_withdrawal_streams:${input.address}`); + const streamWindowMs = + this.config.multiStreamWindowLedgers * LEDGER_MS; + await this.tracker.add(streamKey, input.streamId, 1, now); + const distinctStreams = await this.tracker.distinct( + streamKey, + streamWindowMs, + now, + ); + if (distinctStreams > this.config.multiStreamMaxStreams) { + detected.push( + this.makeIncident({ + ruleId: SENTINEL_RULES.MULTI_STREAM_DRAIN, + severity: + distinctStreams > this.config.multiStreamMaxStreams * 2 + ? 'CRITICAL' + : 'HIGH', + title: 'Rapid multi-stream drain', + description: + `Wallet ${truncate(input.address)} withdrew from ${distinctStreams} ` + + `distinct streams within ${this.config.multiStreamWindowLedgers} ledgers.`, + address: input.address, + token: input.token, + streamId: input.streamId, + ledger: input.ledger, + now, + evidence: { + distinctStreams, + threshold: this.config.multiStreamMaxStreams, + windowLedgers: this.config.multiStreamWindowLedgers, + txHash: input.txHash, + }, + }), + ); + } + + // 3) High-value / near-total balance drain. + const drain = this.evaluateDrain(input, amount, notional); + if (drain) { + detected.push( + this.makeIncident({ + ruleId: SENTINEL_RULES.HIGH_VALUE_DRAIN, + severity: drain.severity, + title: 'High-value stream drain', + description: drain.description, + address: input.address, + token: input.token, + streamId: input.streamId, + ledger: input.ledger, + now, + evidence: { + amount: input.amount, + amountUsd: notional, + streamDeposited: input.streamDeposited ?? null, + drainedPct: drain.drainedPct, + txHash: input.txHash, + }, + }), + ); + } + + // 4) Token-wide velocity spike (catches distributed drains across many + // compromised wallets that individually stay under the per-wallet bar). + const tokenKey = this.registerKey(`token_withdrawal_rate:${input.token}`); + await this.tracker.add(tokenKey, input.txHash, weight, now); + const tokenWindow = await this.tracker.snapshot( + tokenKey, + this.config.velocityWindowMs, + now, + ); + const tokenBaseline = await this.tracker.snapshot( + tokenKey, + this.config.baselineWindowMs, + now, + ); + const tokenVelocity = this.evaluateVelocity( + tokenWindow, + tokenBaseline, + this.config.velocityMinEvents, + ); + if (tokenVelocity) { + detected.push( + this.makeIncident({ + ruleId: SENTINEL_RULES.TOKEN_VELOCITY_SPIKE, + severity: tokenVelocity.severity, + title: 'Token withdrawal velocity spike', + description: + `Token ${truncate(input.token)} saw ${tokenWindow.count} withdrawals in ` + + `${Math.round(this.config.velocityWindowMs / 1000)}s — ` + + `${tokenVelocity.ratioLabel} the trailing baseline.`, + address: input.address, + token: input.token, + streamId: input.streamId, + ledger: input.ledger, + now, + evidence: { + windowCount: tokenWindow.count, + windowVolume: tokenWindow.sum, + baselineVolumePerWindow: tokenVelocity.baselineAvg, + increaseRatio: tokenVelocity.ratio, + windowMs: this.config.velocityWindowMs, + }, + }), + ); + } + + return this.finalize(detected, now); + } + + /** + * Observe a stream creation and evaluate creation-spike and zero-runway + * flood heuristics. + */ + async recordStreamCreation( + input: StreamCreationAnomalyInput, + ): Promise { + if (!this.config.enabled) return []; + const now = input.now ?? this.now(); + const detected: SentinelIncident[] = []; + + const tokenKey = this.registerKey(`stream_creation_spike:${input.token}`); + await this.tracker.add(tokenKey, input.streamId, 1, now); + const created = await this.tracker.distinct( + tokenKey, + this.config.creationSpikeWindowMs, + now, + ); + if (created > this.config.creationSpikeThreshold) { + detected.push( + this.makeIncident({ + ruleId: SENTINEL_RULES.STREAM_CREATION_SPIKE, + severity: + created > this.config.creationSpikeThreshold * 2 ? 'HIGH' : 'MEDIUM', + title: 'Stream creation spike', + description: + `${created} streams for token ${truncate(input.token)} were created ` + + `within ${Math.round(this.config.creationSpikeWindowMs / 60_000)} minutes.`, + address: input.sender, + token: input.token, + streamId: input.streamId, + ledger: input.ledger, + now, + evidence: { + createdInWindow: created, + threshold: this.config.creationSpikeThreshold, + windowMs: this.config.creationSpikeWindowMs, + }, + }), + ); + } + + if (input.runwaySeconds < this.config.zeroRunwayMaxSeconds) { + await this.tracker.add( + this.registerKey('zero_runway_creations'), + input.streamId, + 1, + now, + ); + const flood = await this.tracker.distinct( + 'zero_runway_creations', + this.config.zeroRunwayWindowMs, + now, + ); + if (flood > this.config.zeroRunwayFloodThreshold) { + detected.push( + this.makeIncident({ + ruleId: SENTINEL_RULES.ZERO_RUNWAY_FLOOD, + severity: 'CRITICAL', + title: 'Zero-runway stream flood', + description: + `${flood} streams with under ${this.config.zeroRunwayMaxSeconds}s of ` + + `runway were created in a burst — a classic dust-drain shape.`, + address: input.sender, + token: input.token, + streamId: input.streamId, + ledger: input.ledger, + now, + evidence: { + zeroRunwayStreams: flood, + threshold: this.config.zeroRunwayFloodThreshold, + runwaySeconds: input.runwaySeconds, + }, + }), + ); + } + } + + return this.finalize(detected, now); + } + + // ── Heuristics ────────────────────────────────────────────────────────── + + /** + * Compare a window aggregate against the trailing baseline. The baseline + * excludes the current window so a spike cannot inflate its own reference. + */ + private evaluateVelocity( + window: SlidingWindowSnapshot, + baseline: SlidingWindowSnapshot, + minEvents: number, + ): { + severity: SentinelSeverity; + ratio: number; + ratioLabel: string; + baselineAvg: number; + } | null { + if (window.count < minEvents || window.sum <= 0) return null; + + const baselineWindows = + (this.config.baselineWindowMs - this.config.velocityWindowMs) / + this.config.velocityWindowMs; + const baselineAvg = + baselineWindows > 0 + ? Math.max(0, baseline.sum - window.sum) / baselineWindows + : 0; + + // Cold baseline: any meaningful burst is anomalous. Otherwise require the + // configured multiple (default 4× == a 300% increase). + const ratio = + baselineAvg > 0 ? window.sum / baselineAvg : Number.POSITIVE_INFINITY; + if (baselineAvg > 0 && ratio < this.config.velocitySpikeMultiplier) { + return null; + } + + const multiplier = this.config.velocitySpikeMultiplier; + let severity: SentinelSeverity = 'MEDIUM'; + if (ratio >= multiplier * 3) severity = 'CRITICAL'; + else if (ratio >= multiplier * 1.5) severity = 'HIGH'; + + return { + severity, + ratio, + ratioLabel: Number.isFinite(ratio) + ? `${ratio.toFixed(1)}×` + : 'an unprecedented multiple of', + baselineAvg, + }; + } + + private evaluateDrain( + input: WithdrawalAnomalyInput, + amount: number, + notional: number | null, + ): { severity: SentinelSeverity; description: string; drainedPct: number | null } | null { + const deposited = toNumber(input.streamDeposited); + const drainedPct = deposited > 0 ? (amount / deposited) * 100 : null; + + const notionalBreach = + notional !== null && notional >= this.config.highValueThresholdUsd; + const balanceBreach = + drainedPct !== null && drainedPct >= this.config.balanceDrainPct; + if (!notionalBreach && !balanceBreach) return null; + + const critical = + (notional !== null && + notional >= this.config.highValueThresholdUsd * 2) || + (drainedPct !== null && drainedPct >= 95); + + const description = notionalBreach + ? `A single withdrawal of ~$${Math.round(notional as number).toLocaleString()} ` + + `exceeded the high-value threshold of $${this.config.highValueThresholdUsd.toLocaleString()}.` + : `A single withdrawal drained ${drainedPct?.toFixed(1)}% of stream ` + + `${input.streamId}'s balance (threshold ${this.config.balanceDrainPct}%).`; + + return { severity: critical ? 'CRITICAL' : 'HIGH', description, drainedPct }; + } + + // ── Incident bookkeeping ──────────────────────────────────────────────── + + private makeIncident(params: { + ruleId: SentinelRuleId; + severity: SentinelSeverity; + title: string; + description: string; + address: string | null; + token: string | null; + streamId: string | null; + ledger: number | null; + now: number; + evidence: Record; + }): SentinelIncident { + return { + id: crypto.randomUUID(), + ruleId: params.ruleId, + severity: params.severity, + title: params.title, + description: params.description, + address: params.address, + token: params.token, + streamId: params.streamId, + ledger: params.ledger, + detectedAt: new Date(params.now).toISOString(), + threatScore: SEVERITY_WEIGHT[params.severity], + evidence: params.evidence, + circuitBreaker: null, + acknowledged: false, + }; + } + + /** + * Deduplicate (cooldown), persist, dispatch, and — for CRITICAL incidents — + * attach a signed emergency-pause proposal. Shared by every rule. + */ + private async finalize( + incidents: SentinelIncident[], + now: number, + ): Promise { + const accepted: SentinelIncident[] = []; + + for (const incident of incidents) { + const key = this.cooldownKey(incident); + const lastAlertedAt = this.cooldowns.get(key); + if ( + lastAlertedAt !== undefined && + now - lastAlertedAt < this.config.alertCooldownMs + ) { + continue; + } + this.cooldowns.set(key, now); + + if (incident.severity === 'CRITICAL') { + incident.circuitBreaker = this.buildEmergencyPauseProposal(incident, now); + } + + this.incidents.unshift(incident); + sentinelIncidentsTotal.inc({ ruleId: incident.ruleId, severity: incident.severity }); + + await this.dispatchAlert(incident); + accepted.push(incident); + } + + if (accepted.length > 0) { + this.prune(now); + setSentinelThreatScore(this.getThreatScore(now).score); + } + + return accepted; + } + + private cooldownKey(incident: SentinelIncident): string { + return [ + incident.ruleId, + incident.address ?? 'global', + incident.token ?? '', + incident.streamId ?? '', + ].join('|'); + } + + private prune(now: number): void { + const cutoff = now - this.config.retentionMs; + while (this.incidents.length > 0) { + const oldest = this.incidents[this.incidents.length - 1]; + if ( + this.incidents.length > MAX_INCIDENTS || + (oldest && Date.parse(oldest.detectedAt) < cutoff) + ) { + this.incidents.pop(); + } else { + break; + } + } + const cooldownCutoff = now - this.config.alertCooldownMs; + for (const [key, at] of this.cooldowns) { + if (at < cooldownCutoff) this.cooldowns.delete(key); + } + } + + /** + * Build the HMAC-signed emergency-pause proposal handed to multisig signers. + */ + buildEmergencyPauseProposal( + incident: SentinelIncident, + now = this.now(), + ): EmergencyPauseProposal { + const requestedAt = new Date(now).toISOString(); + const reason = `${incident.ruleId}: ${incident.title}`; + const canonical = JSON.stringify({ + action: 'set_emergency_pause', + value: true, + incidentId: incident.id, + reason, + requestedAt, + }); + return { + action: 'set_emergency_pause', + value: true, + incidentId: incident.id, + reason, + requestedAt, + payloadHash: crypto.createHash('sha256').update(canonical).digest('hex'), + signature: crypto + .createHmac('sha256', this.config.proposalSecret) + .update(canonical) + .digest('hex'), + algorithm: 'hmac-sha256', + }; + } + + // ── Alerting ──────────────────────────────────────────────────────────── + + /** + * Fan an incident out to every configured channel. Delivery failures are + * recorded and logged but never thrown: a stale PagerDuty key must not stop + * the next incident from being detected. + */ + async dispatchAlert( + incident: SentinelIncident, + ): Promise { + const cfg = this.config; + const channels: Array<{ + channel: SentinelAlertDelivery['channel']; + url: string; + body: unknown; + }> = []; + + if (cfg.slackWebhookUrl) { + channels.push({ + channel: 'slack', + url: cfg.slackWebhookUrl, + body: this.buildSlackPayload(incident), + }); + } + if (cfg.discordWebhookUrl) { + channels.push({ + channel: 'discord', + url: cfg.discordWebhookUrl, + body: this.buildDiscordPayload(incident), + }); + } + if (cfg.pagerDutyRoutingKey) { + channels.push({ + channel: 'pagerduty', + url: 'https://events.pagerduty.com/v2/enqueue', + body: this.buildPagerDutyPayload(incident), + }); + } + + if (channels.length === 0) { + logger.warn( + `[Sentinel] ${incident.severity} ${incident.ruleId}: ${incident.description} ` + + '(no alert channels configured)', + ); + return []; + } + + const deliveries = await Promise.all( + channels.map(async ({ channel, url, body }): Promise => { + try { + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + const ok = response.status >= 200 && response.status < 300; + sentinelAlertsDispatchedTotal.inc({ + channel, + outcome: ok ? 'sent' : 'failed', + }); + if (!ok) { + logger.warn( + `[Sentinel] ${channel} alert for ${incident.id} returned HTTP ${response.status}`, + ); + } + return { channel, ok, status: response.status }; + } catch (error) { + sentinelAlertsDispatchedTotal.inc({ channel, outcome: 'error' }); + const message = error instanceof Error ? error.message : 'unknown error'; + logger.error(`[Sentinel] ${channel} alert for ${incident.id} failed:`, error); + return { channel, ok: false, error: message }; + } + }), + ); + + return deliveries; + } + + private buildAlertContext(incident: SentinelIncident) { + return { + ruleId: incident.ruleId, + severity: incident.severity, + threatScore: incident.threatScore, + address: incident.address, + token: incident.token, + streamId: incident.streamId, + ledger: incident.ledger, + detectedAt: incident.detectedAt, + evidence: incident.evidence, + circuitBreaker: incident.circuitBreaker, + }; + } + + private buildSlackPayload(incident: SentinelIncident) { + return { + text: `🚨 [${incident.severity}] ${incident.title}`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: + `*🚨 ${incident.severity} — ${incident.title}*\n${incident.description}` + + (incident.circuitBreaker + ? '\n*Circuit breaker:* signed `set_emergency_pause(true)` proposal attached — multisig action required.' + : ''), + }, + }, + { type: 'context', elements: [{ type: 'mrkdwn', text: `incident \`${incident.id}\`` }] }, + ], + sentinel: this.buildAlertContext(incident), + }; + } + + private buildDiscordPayload(incident: SentinelIncident) { + return { + content: `🚨 **[${incident.severity}] ${incident.title}** — ${incident.description}`, + embeds: [ + { + title: `${incident.ruleId} (${incident.severity})`, + description: incident.description, + color: incident.severity === 'CRITICAL' ? 0xdc2626 : 0xf59e0b, + timestamp: incident.detectedAt, + fields: [ + ...(incident.address + ? [{ name: 'Address', value: `\`${incident.address}\``, inline: true }] + : []), + ...(incident.token + ? [{ name: 'Token', value: `\`${incident.token}\``, inline: true }] + : []), + { name: 'Threat score', value: String(incident.threatScore), inline: true }, + ], + }, + ], + sentinel: this.buildAlertContext(incident), + }; + } + + private buildPagerDutyPayload(incident: SentinelIncident) { + return { + routing_key: this.config.pagerDutyRoutingKey, + event_action: 'trigger', + dedup_key: incident.id, + payload: { + summary: `[${incident.severity}] ${incident.title}: ${incident.description}`, + source: 'flowfi-sentinel', + severity: + incident.severity === 'CRITICAL' + ? 'critical' + : incident.severity === 'HIGH' + ? 'error' + : incident.severity === 'MEDIUM' + ? 'warning' + : 'info', + timestamp: incident.detectedAt, + custom_details: this.buildAlertContext(incident), + }, + }; + } + + // ── Read APIs (admin dashboard) ───────────────────────────────────────── + + /** Most recent incidents first, optionally filtered. */ + getAlerts(options: { + severity?: SentinelSeverity; + address?: string; + limit?: number; + now?: number; + } = {}): SentinelIncident[] { + const limit = Math.min(Math.max(options.limit ?? 50, 1), 200); + const cutoff = + options.now !== undefined ? options.now - this.config.retentionMs : undefined; + return this.incidents + .filter( + (incident) => + (!options.severity || incident.severity === options.severity) && + (!options.address || incident.address === options.address) && + (cutoff === undefined || Date.parse(incident.detectedAt) >= cutoff), + ) + .slice(0, limit); + } + + /** Aggregate 0-100 threat score derived from the trailing window. */ + getThreatScore(now = this.now()): SentinelThreatSummary { + const cutoff = now - this.config.threatWindowMs; + const bySeverity: Record = { + LOW: 0, + MEDIUM: 0, + HIGH: 0, + CRITICAL: 0, + }; + + let raw = 0; + let incidentCount = 0; + for (const incident of this.incidents) { + if (Date.parse(incident.detectedAt) < cutoff) continue; + incidentCount += 1; + raw += SEVERITY_WEIGHT[incident.severity]; + bySeverity[incident.severity] += 1; + } + + const score = Math.min(100, raw); + const level: SentinelSeverity | 'NONE' = + score === 0 + ? 'NONE' + : score >= 80 + ? 'CRITICAL' + : score >= 50 + ? 'HIGH' + : score >= 25 + ? 'MEDIUM' + : 'LOW'; + + return { + score, + level, + incidentCount, + bySeverity, + windowMinutes: Math.round(this.config.threatWindowMs / 60_000), + generatedAt: new Date(now).toISOString(), + }; + } + + /** Addresses implicated in recent incidents, ranked by accumulated score. */ + getFlaggedAddresses(now = this.now()): SentinelFlaggedAddress[] { + const cutoff = now - this.config.threatWindowMs; + const byAddress = new Map(); + + for (const incident of this.incidents) { + if (!incident.address) continue; + if (Date.parse(incident.detectedAt) < cutoff) continue; + + const existing = byAddress.get(incident.address); + if (!existing) { + byAddress.set(incident.address, { + address: incident.address, + threatScore: incident.threatScore, + incidentCount: 1, + highestSeverity: incident.severity, + lastIncidentAt: incident.detectedAt, + }); + continue; + } + existing.threatScore = Math.min(100, existing.threatScore + incident.threatScore); + existing.incidentCount += 1; + existing.highestSeverity = higherSeverity( + existing.highestSeverity, + incident.severity, + ); + if (incident.detectedAt > existing.lastIncidentAt) { + existing.lastIncidentAt = incident.detectedAt; + } + } + + return [...byAddress.values()].sort((a, b) => b.threatScore - a.threatScore); + } + + /** Mark an incident reviewed. Returns false when the id is unknown. */ + acknowledgeAlert(id: string): boolean { + const incident = this.incidents.find((entry) => entry.id === id); + if (!incident) return false; + incident.acknowledged = true; + return true; + } + + /** Drop all retained state. Intended for tests and admin resets. */ + async reset(): Promise { + this.incidents.length = 0; + this.cooldowns.clear(); + setSentinelThreatScore(0); + for (const key of this.trackedKeys) { + await this.tracker.clear(key); + } + this.trackedKeys.clear(); + } +} + +/** + * Process-wide sentinel used by the indexer hot path and the admin API. + * Config is read lazily on first use so tests can mutate `process.env` before + * importing. + */ +let _sentinel: SentinelService | null = null; + +export function getSentinelService(): SentinelService { + if (!_sentinel) { + _sentinel = new SentinelService(); + } + return _sentinel; +} + +export const sentinelService = { + recordWithdrawal: (input: WithdrawalAnomalyInput) => + getSentinelService().recordWithdrawal(input), + recordStreamCreation: (input: StreamCreationAnomalyInput) => + getSentinelService().recordStreamCreation(input), + dispatchAlert: (incident: SentinelIncident) => + getSentinelService().dispatchAlert(incident), + getAlerts: (options?: Parameters[0]) => + getSentinelService().getAlerts(options), + getThreatScore: () => getSentinelService().getThreatScore(), + getFlaggedAddresses: () => getSentinelService().getFlaggedAddresses(), + acknowledgeAlert: (id: string) => getSentinelService().acknowledgeAlert(id), + buildEmergencyPauseProposal: (incident: SentinelIncident) => + getSentinelService().buildEmergencyPauseProposal(incident), + reset: () => getSentinelService().reset(), +}; + +export default sentinelService; diff --git a/backend/src/workers/soroban-event-worker.ts b/backend/src/workers/soroban-event-worker.ts index 2858a894..c850d79d 100644 --- a/backend/src/workers/soroban-event-worker.ts +++ b/backend/src/workers/soroban-event-worker.ts @@ -3,6 +3,7 @@ import { rpc, xdr, StrKey } from "@stellar/stellar-sdk"; import { prisma } from "../lib/prisma.js"; import { INDEXER_STATE_ID, ensureIndexerState } from "../lib/indexer-state.js"; import { sseService } from "../services/sse.service.js"; +import { sentinelService } from "../services/sentinel.service.js"; import { publishIndexerLag, quarantineEvent } from "../services/indexerService.js"; import { indexerEventsProcessedTotal, @@ -802,6 +803,13 @@ export class SorobanEventWorker { ? null : startTime + BigInt(depositedAmount) / ratePerSecondBigInt; + // Runway is how long the deposit lasts at the current rate; a zero rate + // never depletes. Feeds the sentinel's zero-runway flood heuristic. + const runwaySeconds = + ratePerSecondBigInt > 0n + ? Number(BigInt(depositedAmount) / ratePerSecondBigInt) + : Number.POSITIVE_INFINITY; + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.user.upsert({ where: { publicKey: sender }, @@ -889,6 +897,23 @@ export class SorobanEventWorker { transactionHash: event.txHash, ledger: event.ledger, }); + + // Sentinel anomaly detection (#1469) is best-effort: an analysis failure + // must never quarantine a well-formed on-chain event. + try { + await sentinelService.recordStreamCreation({ + sender, + token: tokenAddress, + streamId: String(streamId), + runwaySeconds, + ledger: event.ledger, + }); + } catch (error) { + logger.warn( + `[SorobanWorker] Sentinel stream-creation analysis failed for #${streamId}:`, + error, + ); + } } private async handleStreamToppedUp( @@ -999,6 +1024,11 @@ export class SorobanEventWorker { const amount = decodeI128(body["amount"]); const timestamp = Number(decodeU64(body["timestamp"])); + // Captured inside the transaction so the sentinel can weigh this + // withdrawal against the stream's total deposit once it commits. + let streamDeposited: string | undefined; + let streamToken: string | undefined; + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { // Check for a duplicate BEFORE mutating any Stream fields so that a // replayed event never double-increments withdrawnAmount. @@ -1020,8 +1050,14 @@ export class SorobanEventWorker { const stream = await tx.stream.findUniqueOrThrow({ where: { streamId }, - select: { withdrawnAmount: true }, + select: { + withdrawnAmount: true, + depositedAmount: true, + tokenAddress: true, + }, }); + streamDeposited = stream.depositedAmount; + streamToken = stream.tokenAddress; const newWithdrawnAmount = ( BigInt(stream.withdrawnAmount) + BigInt(amount) @@ -1066,6 +1102,25 @@ export class SorobanEventWorker { ledger: event.ledger, timestamp, }); + + // Feed the drain sentinel (#1469). Best-effort: analysis must not + // quarantine a valid withdrawal event. + try { + await sentinelService.recordWithdrawal({ + address: recipient, + token: streamToken ?? "unknown", + amount, + streamId: String(streamId), + ledger: event.ledger, + txHash: event.txHash, + ...(streamDeposited !== undefined ? { streamDeposited } : {}), + }); + } catch (error) { + logger.warn( + `[SorobanWorker] Sentinel withdrawal analysis failed for #${streamId}:`, + error, + ); + } } private async handleStreamCancelled( diff --git a/backend/swagger/flowfi.openapi.json b/backend/swagger/flowfi.openapi.json index 1b32ef15..8d944400 100644 --- a/backend/swagger/flowfi.openapi.json +++ b/backend/swagger/flowfi.openapi.json @@ -47,6 +47,10 @@ { "name": "Observability", "description": "Prometheus metrics scrape endpoint" + }, + { + "name": "Compliance", + "description": "Sanctions / OFAC screening and SEP-0009 KYC attestation endpoints" } ], "components": { @@ -706,6 +710,88 @@ } } }, + "SentinelIncident": { + "type": "object", + "required": [ + "id", + "ruleId", + "severity", + "title", + "description", + "detectedAt", + "threatScore" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "ruleId": { + "type": "string", + "enum": [ + "VELOCITY_SPIKE", + "MULTI_STREAM_DRAIN", + "HIGH_VALUE_DRAIN", + "TOKEN_VELOCITY_SPIKE", + "ZERO_RUNWAY_FLOOD", + "STREAM_CREATION_SPIKE" + ] + }, + "severity": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL" + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "address": { + "type": "string", + "nullable": true, + "description": "Stellar public key involved" + }, + "token": { + "type": "string", + "nullable": true + }, + "streamId": { + "type": "string", + "nullable": true + }, + "ledger": { + "type": "integer", + "nullable": true + }, + "detectedAt": { + "type": "string", + "format": "date-time" + }, + "threatScore": { + "type": "integer", + "description": "Per-incident severity weight (10-90)" + }, + "evidence": { + "type": "object", + "additionalProperties": true + }, + "circuitBreaker": { + "type": "object", + "nullable": true, + "description": "HMAC-signed emergency pause proposal (CRITICAL incidents only)", + "additionalProperties": true + }, + "acknowledged": { + "type": "boolean" + } + } + }, "HealthResponse": { "type": "object", "required": [ @@ -3178,6 +3264,161 @@ } } }, + "/v1/compliance/kyc-attestation": { + "post": { + "tags": [ + "Compliance" + ], + "summary": "Submit a SEP-0009 KYC/AML attestation", + "description": "Allows an organization to submit a cryptographic proof of identity\nverification for a wallet, following the SEP-0009 (Standard KYC / AML\nFields) schema. Attestations are stored as PENDING for out-of-band\nreview and are recorded in the compliance audit trail.\n", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "subjectAddress", + "proof", + "fields" + ], + "properties": { + "subjectAddress": { + "type": "string", + "description": "Stellar public key the identity belongs to" + }, + "organization": { + "type": "string", + "description": "Organization identifier; defaults to the authenticated wallet" + }, + "proof": { + "type": "string", + "description": "Cryptographic proof / signature over the identity payload" + }, + "proofType": { + "type": "string", + "enum": [ + "ed25519", + "secp256k1", + "stellar-signature" + ] + }, + "fields": { + "type": "object", + "description": "SEP-0009 KYC/AML fields (snake_case, vendor fields passthrough)" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Attestation accepted for review" + }, + "400": { + "description": "Invalid attestation payload" + }, + "401": { + "description": "Unauthorized - missing or invalid authentication" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/v1/compliance/screen": { + "post": { + "tags": [ + "Compliance" + ], + "summary": "Screen an address against sanctions data", + "description": "Runs the configured sanctions / risk screening provider for a wallet\naddress and returns the result. Results are cached for the configured\nTTL. Requires authentication to prevent open enumeration.\n", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "description": "Stellar public key to screen" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Screening result" + }, + "400": { + "description": "Invalid request body" + }, + "401": { + "description": "Unauthorized - missing or invalid authentication" + }, + "503": { + "description": "Screening provider unavailable" + } + } + } + }, + "/v1/compliance/screen/{address}": { + "get": { + "tags": [ + "Compliance" + ], + "summary": "Screen a wallet address (read-only)", + "description": "Convenience GET variant of /v1/compliance/screen for audit tooling.", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "address", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stellar public key to screen" + } + ], + "responses": { + "200": { + "description": "Screening result" + }, + "400": { + "description": "Address is required" + }, + "401": { + "description": "Unauthorized - missing or invalid authentication" + }, + "503": { + "description": "Screening provider unavailable" + } + } + } + }, "/v1/auth/challenge": { "post": { "tags": [ @@ -3924,6 +4165,175 @@ } } } + }, + "/v1/admin/sentinel/alerts": { + "get": { + "tags": [ + "Admin" + ], + "summary": "List sentinel anomalies, threat score, and flagged addresses", + "description": "Returns the real-time anomaly dashboard backing the drain sentinel:\nthe aggregate 0-100 threat score, the addresses implicated in recent\nanomalies, and the incident history (newest first). Filter by\n`severity`, `address`, or page with `limit`.\n", + "security": [ + { + "adminAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "severity", + "schema": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL" + ] + } + }, + { + "in": "query", + "name": "address", + "schema": { + "type": "string" + }, + "description": "Stellar public key to filter incidents by" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Sentinel dashboard snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "threatScore": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "example": 75 + }, + "level": { + "type": "string", + "enum": [ + "NONE", + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL" + ] + }, + "incidentCount": { + "type": "integer" + }, + "bySeverity": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "windowMinutes": { + "type": "integer" + } + } + }, + "flaggedAddresses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "threatScore": { + "type": "integer" + }, + "incidentCount": { + "type": "integer" + }, + "highestSeverity": { + "type": "string" + }, + "lastIncidentAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "incidents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SentinelIncident" + } + }, + "count": { + "type": "integer" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "400": { + "description": "Invalid severity filter" + }, + "401": { + "description": "Unauthorized - missing or invalid authentication token" + }, + "403": { + "description": "Forbidden - admin access required" + } + } + } + }, + "/v1/admin/sentinel/alerts/{id}/acknowledge": { + "post": { + "tags": [ + "Admin" + ], + "summary": "Acknowledge a sentinel incident", + "security": [ + { + "adminAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Incident acknowledged" + }, + "404": { + "description": "Incident not found" + } + } + } } } } diff --git a/backend/tests/compliance.test.ts b/backend/tests/compliance.test.ts new file mode 100644 index 00000000..7bfa92de --- /dev/null +++ b/backend/tests/compliance.test.ts @@ -0,0 +1,584 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import express, { type Request, type Response, type NextFunction } from 'express'; +import request from 'supertest'; + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: { + complianceAuditLog: { create: vi.fn().mockResolvedValue({ id: 'audit-1' }) }, + kycAttestation: { create: vi.fn() }, + }, +})); + +vi.mock('../src/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../src/middleware/auth.js', () => ({ + requireAuth: (req: Request, _res: Response, next: NextFunction) => { + (req as unknown as { user: { publicKey: string } }).user = { + publicKey: 'GAUTHENTICATEDORGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }; + next(); + }, +})); + +import { getComplianceConfig } from '../src/config/compliance.config.js'; +import type { ComplianceConfig } from '../src/config/compliance.config.js'; +import { + ComplianceService, + ComplianceScreeningError, + type ScreeningResult, + type ComplianceAuditEvent, +} from '../src/services/compliance.service.js'; +import { + complianceScreening, + extractComplianceAddresses, + type ComplianceScreeningService, +} from '../src/middleware/compliance.middleware.js'; +import complianceRoutes from '../src/routes/v1/compliance.routes.js'; +import { prisma } from '../src/lib/prisma.js'; + +const USER = 'GAUTHENTICATEDORGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const CLEAN = 'GCLEANADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const SANCTIONED = 'GOFACSANCTIONEDADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const ALLOWLISTED = 'GALLOWLISTEDADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MW_SANCTIONED = 'GMWSANCTIONEDADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function config(overrides: Partial = {}): ComplianceConfig { + return { + enforcementEnabled: true, + riskThreshold: 70, + failureMode: 'fail-closed', + cacheTtlSeconds: 3600, + provider: 'local', + externalApiTimeoutMs: 1000, + allowlist: [], + denylist: [], + ...overrides, + }; +} + +function fakeService( + overrides: Partial = {}, +): ComplianceScreeningService { + return { + screenAddresses: vi.fn(async (addresses: string[]) => + addresses.map((address) => ({ + address, + isSanctioned: false, + riskScore: 0, + tags: [], + screenedAt: new Date(), + cached: false, + })), + ), + isBlocked: (result, cfg) => result.isSanctioned || result.riskScore > cfg.riskThreshold, + writeAuditLog: vi.fn(async (_event: ComplianceAuditEvent) => undefined), + ...overrides, + }; +} + +function buildApp(cfg: ComplianceConfig, service?: ComplianceScreeningService) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { user: { publicKey: string } }).user = { publicKey: USER }; + next(); + }); + app.post( + '/streams', + complianceScreening({ action: 'stream.create', config: cfg, ...(service ? { service } : {}) }), + (_req: Request, res: Response) => res.status(201).json({ ok: true }), + ); + return app; +} + +describe('Compliance configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + for (const key of [ + 'COMPLIANCE_ENFORCEMENT_ENABLED', + 'COMPLIANCE_RISK_THRESHOLD', + 'COMPLIANCE_FAILURE_MODE', + 'COMPLIANCE_CACHE_TTL_SECONDS', + 'COMPLIANCE_PROVIDER', + 'COMPLIANCE_ALLOWLIST', + 'COMPLIANCE_DENYLIST', + 'COMPLIANCE_EXTERNAL_API_URL', + 'COMPLIANCE_EXTERNAL_API_KEY', + 'COMPLIANCE_EXTERNAL_API_TIMEOUT_MS', + 'COMPLIANCE_SANCTIONS_FILE', + ]) { + delete process.env[key]; + } + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('defaults to disabled enforcement (open-source / local testing friendly)', () => { + const cfg = getComplianceConfig(); + expect(cfg.enforcementEnabled).toBe(false); + expect(cfg.riskThreshold).toBe(70); + expect(cfg.failureMode).toBe('fail-closed'); + expect(cfg.provider).toBe('local'); + expect(cfg.cacheTtlSeconds).toBe(86400); + }); + + it('parses enforcement, threshold, provider and failure mode', () => { + process.env.COMPLIANCE_ENFORCEMENT_ENABLED = 'true'; + process.env.COMPLIANCE_RISK_THRESHOLD = '40'; + process.env.COMPLIANCE_PROVIDER = 'external'; + process.env.COMPLIANCE_FAILURE_MODE = 'fail-open'; + process.env.COMPLIANCE_EXTERNAL_API_URL = 'https://provider.test/screen'; + process.env.COMPLIANCE_EXTERNAL_API_KEY = 'secret'; + + const cfg = getComplianceConfig(); + expect(cfg.enforcementEnabled).toBe(true); + expect(cfg.riskThreshold).toBe(40); + expect(cfg.provider).toBe('external'); + expect(cfg.failureMode).toBe('fail-open'); + expect(cfg.externalApiUrl).toBe('https://provider.test/screen'); + expect(cfg.externalApiKey).toBe('secret'); + }); + + it('parses comma-separated allow and deny lists, trimming whitespace', () => { + process.env.COMPLIANCE_ALLOWLIST = ' GA, GB ,'; + process.env.COMPLIANCE_DENYLIST = 'GX,GY'; + const cfg = getComplianceConfig(); + expect(cfg.allowlist).toEqual(['GA', 'GB']); + expect(cfg.denylist).toEqual(['GX', 'GY']); + }); + + it('throws on invalid boolean, number, provider and failure mode values', () => { + process.env.COMPLIANCE_ENFORCEMENT_ENABLED = 'yes'; + expect(() => getComplianceConfig()).toThrowError(/COMPLIANCE_ENFORCEMENT_ENABLED/); + delete process.env.COMPLIANCE_ENFORCEMENT_ENABLED; + + process.env.COMPLIANCE_RISK_THRESHOLD = '101'; + expect(() => getComplianceConfig()).toThrowError(/COMPLIANCE_RISK_THRESHOLD/); + delete process.env.COMPLIANCE_RISK_THRESHOLD; + + process.env.COMPLIANCE_RISK_THRESHOLD = 'abc'; + expect(() => getComplianceConfig()).toThrowError(/COMPLIANCE_RISK_THRESHOLD/); + delete process.env.COMPLIANCE_RISK_THRESHOLD; + + process.env.COMPLIANCE_PROVIDER = 'chainalysis'; + expect(() => getComplianceConfig()).toThrowError(/COMPLIANCE_PROVIDER/); + delete process.env.COMPLIANCE_PROVIDER; + + process.env.COMPLIANCE_FAILURE_MODE = 'open'; + expect(() => getComplianceConfig()).toThrowError(/COMPLIANCE_FAILURE_MODE/); + delete process.env.COMPLIANCE_FAILURE_MODE; + }); +}); + +describe('ComplianceService screening', () => { + let service: ComplianceService; + + beforeEach(() => { + service = new ComplianceService(); + }); + + it('returns a clean result for an unknown address', async () => { + const result = await service.screenAddress(CLEAN, config()); + expect(result).toMatchObject({ + address: CLEAN, + isSanctioned: false, + riskScore: 0, + cached: false, + }); + expect(result.screenedAt).toBeInstanceOf(Date); + }); + + it('flags denylisted addresses as sanctioned with OFAC tags', async () => { + const cfg = config({ denylist: [SANCTIONED] }); + const result = await service.screenAddress(SANCTIONED, cfg); + expect(result.isSanctioned).toBe(true); + expect(result.riskScore).toBe(100); + expect(result.tags).toContain('OFAC'); + }); + + it('lets the allowlist override the denylist', async () => { + const cfg = config({ denylist: [ALLOWLISTED], allowlist: [ALLOWLISTED] }); + const result = await service.screenAddress(ALLOWLISTED, cfg); + expect(result.isSanctioned).toBe(false); + expect(result.riskScore).toBe(0); + }); + + it('caches results and reports cached=true on the second lookup', async () => { + const address = 'GCACHETESTADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const cfg = config(); + const first = await service.screenAddress(address, cfg); + expect(first.cached).toBe(false); + + const second = await service.screenAddress(address, cfg); + expect(second.cached).toBe(true); + expect(second.isSanctioned).toBe(first.isSanctioned); + }); + + it('deduplicates addresses when screening a batch', async () => { + const results = await service.screenAddresses( + ['GBATCHONEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'GBATCHONEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'], + config(), + ); + expect(results).toHaveLength(1); + }); + + it('applies the risk threshold strictly greater-than', () => { + const cfg = config({ riskThreshold: 70 }); + const below: ScreeningResult = { + address: CLEAN, + isSanctioned: false, + riskScore: 70, + tags: [], + screenedAt: new Date(), + cached: false, + }; + const above: ScreeningResult = { ...below, riskScore: 71 }; + expect(service.isBlocked(below, cfg)).toBe(false); + expect(service.isBlocked(above, cfg)).toBe(true); + }); + + describe('external provider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('parses a successful external screening response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ isSanctioned: true, riskScore: 99, tags: ['OFAC', 'Darknet'] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const cfg = config({ + provider: 'external', + externalApiUrl: 'https://provider.test/screen', + externalApiKey: 'key', + }); + const result = await service.screenAddress( + 'GEXTERNALONEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + cfg, + ); + + expect(result.isSanctioned).toBe(true); + expect(result.riskScore).toBe(99); + expect(result.tags).toEqual(['OFAC', 'Darknet']); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('throws ComplianceScreeningError on a non-OK response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) })); + const cfg = config({ + provider: 'external', + externalApiUrl: 'https://provider.test/screen', + }); + await expect( + service.screenAddress('GEXTERNALTWOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', cfg), + ).rejects.toBeInstanceOf(ComplianceScreeningError); + }); + + it('throws ComplianceScreeningError on network failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + const cfg = config({ + provider: 'external', + externalApiUrl: 'https://provider.test/screen', + }); + await expect( + service.screenAddress('GEXTERNALTHREEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', cfg), + ).rejects.toBeInstanceOf(ComplianceScreeningError); + }); + + it('throws when no endpoint is configured', async () => { + await expect( + service.screenAddress( + 'GEXTERNALFOURAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + config({ provider: 'external' }), + ), + ).rejects.toBeInstanceOf(ComplianceScreeningError); + }); + }); + + describe('audit logging', () => { + it('persists an audit event through prisma', async () => { + await service.writeAuditLog({ + eventType: 'SCREENING_BLOCKED', + address: SANCTIONED, + ipAddress: '203.0.113.7', + riskScore: 100, + tags: ['OFAC'], + isSanctioned: true, + action: 'stream.create', + }); + expect(prisma.complianceAuditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + address: SANCTIONED, + ipAddress: '203.0.113.7', + riskScore: 100, + isSanctioned: true, + }), + }), + ); + }); + + it('never throws when the audit table write fails', async () => { + (prisma.complianceAuditLog.create as any).mockRejectedValueOnce(new Error('db down')); + await expect( + service.writeAuditLog({ + eventType: 'SCREENING_BLOCKED', + address: SANCTIONED, + riskScore: 100, + tags: ['OFAC'], + isSanctioned: true, + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('SEP-0009 KYC attestation', () => { + it('stores a SEP-0009 attestation', async () => { + (prisma.kycAttestation.create as any).mockResolvedValueOnce({ + id: 'att-1', + organization: 'GORG', + subjectAddress: CLEAN, + status: 'PENDING', + createdAt: new Date(), + }); + + const attestation = await service.submitKycAttestation({ + organization: 'GORG', + subjectAddress: CLEAN, + sep9Fields: { first_name: 'Ada', last_name: 'Lovelace' }, + proof: 'signed-proof', + proofType: 'ed25519', + }); + + expect(attestation.id).toBe('att-1'); + expect(prisma.kycAttestation.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organization: 'GORG', + subjectAddress: CLEAN, + proofType: 'ed25519', + status: 'PENDING', + }), + }); + }); + }); +}); + +describe('Compliance screening middleware', () => { + it('extracts addresses from the authenticated user and request body', () => { + const req = { + body: { sender: USER, recipient: SANCTIONED }, + params: {}, + query: {}, + user: { publicKey: USER }, + } as unknown as Request; + const addresses = extractComplianceAddresses(req); + expect(addresses).toEqual([USER, SANCTIONED]); + }); + + it('is a no-op when enforcement is disabled', async () => { + const service = fakeService(); + const app = buildApp(config({ enforcementEnabled: false, denylist: [SANCTIONED] }), service); + + const res = await request(app).post('/streams').send({ sender: USER, recipient: SANCTIONED }); + expect(res.status).toBe(201); + expect(service.screenAddresses).not.toHaveBeenCalled(); + }); + + it('allows clean addresses through', async () => { + const app = buildApp(config({ denylist: [SANCTIONED] })); + const res = await request(app).post('/streams').send({ sender: USER, recipient: CLEAN }); + expect(res.status).toBe(201); + }); + + it('blocks sanctioned addresses with a structured 403 and writes an audit event', async () => { + // Uses the real local-provider service so the denylist is actually consulted. + const app = buildApp(config({ denylist: [MW_SANCTIONED] })); + + const res = await request(app).post('/streams').send({ sender: USER, recipient: MW_SANCTIONED }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('COMPLIANCE_RESTRICTION'); + expect(res.body.message).toBe('Address restricted under compliance policy'); + expect(prisma.complianceAuditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ eventType: 'SCREENING_BLOCKED', isSanctioned: true }), + }), + ); + }); + + it('blocks addresses whose risk score exceeds the threshold', async () => { + const service = fakeService({ + screenAddresses: vi.fn(async (addresses: string[]) => + addresses.map((address) => ({ + address, + isSanctioned: false, + riskScore: 85, + tags: ['MIXER'], + screenedAt: new Date(), + cached: false, + })), + ), + }); + const app = buildApp(config({ riskThreshold: 70 }), service); + + const res = await request(app).post('/streams').send({ sender: USER, recipient: CLEAN }); + expect(res.status).toBe(403); + expect(res.body.error).toBe('COMPLIANCE_RESTRICTION'); + }); + + it('allows an address whose risk score equals the threshold', async () => { + const service = fakeService({ + screenAddresses: vi.fn(async (addresses: string[]) => + addresses.map((address) => ({ + address, + isSanctioned: false, + riskScore: 70, + tags: [], + screenedAt: new Date(), + cached: false, + })), + ), + }); + const app = buildApp(config({ riskThreshold: 70 }), service); + + const res = await request(app).post('/streams').send({ sender: USER, recipient: CLEAN }); + expect(res.status).toBe(201); + }); + + it('fails closed with 503 when screening errors and failure mode is fail-closed', async () => { + const service = fakeService({ + screenAddresses: vi.fn().mockRejectedValue(new ComplianceScreeningError('provider down')), + }); + const app = buildApp(config({ failureMode: 'fail-closed' }), service); + + const res = await request(app).post('/streams').send({ sender: USER, recipient: CLEAN }); + + expect(res.status).toBe(503); + expect(res.body.error).toBe('COMPLIANCE_SCREENING_UNAVAILABLE'); + expect(service.writeAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'SCREENING_ERROR' }), + ); + }); + + it('fails open when screening errors and failure mode is fail-open', async () => { + const service = fakeService({ + screenAddresses: vi.fn().mockRejectedValue(new ComplianceScreeningError('provider down')), + }); + const app = buildApp(config({ failureMode: 'fail-open' }), service); + + const res = await request(app).post('/streams').send({ sender: USER, recipient: CLEAN }); + + expect(res.status).toBe(201); + expect(service.writeAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'SCREENING_ERROR' }), + ); + }); +}); + +describe('Compliance routes', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.COMPLIANCE_ENFORCEMENT_ENABLED; + delete process.env.COMPLIANCE_DENYLIST; + delete process.env.COMPLIANCE_ALLOWLIST; + delete process.env.COMPLIANCE_PROVIDER; + vi.clearAllMocks(); + (prisma.complianceAuditLog.create as any).mockResolvedValue({ id: 'audit-1' }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + function app() { + const instance = express(); + instance.use(express.json()); + instance.use('/compliance', complianceRoutes); + return instance; + } + + it('accepts a valid SEP-0009 KYC attestation', async () => { + (prisma.kycAttestation.create as any).mockResolvedValueOnce({ + id: 'att-42', + organization: USER, + subjectAddress: CLEAN, + status: 'PENDING', + createdAt: new Date().toISOString(), + }); + + const res = await request(app()) + .post('/compliance/kyc-attestation') + .send({ + subjectAddress: CLEAN, + proof: 'signed-proof', + proofType: 'ed25519', + fields: { first_name: 'Ada', last_name: 'Lovelace', email_address: 'ada@example.com' }, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.attestation.id).toBe('att-42'); + expect(prisma.kycAttestation.create).toHaveBeenCalledOnce(); + }); + + it('rejects an attestation missing the required proof', async () => { + const res = await request(app()) + .post('/compliance/kyc-attestation') + .send({ subjectAddress: CLEAN, fields: { first_name: 'Ada' } }); + + expect(res.status).toBe(400); + expect(prisma.kycAttestation.create).not.toHaveBeenCalled(); + }); + + it('rejects an attestation with an empty SEP-0009 field set', async () => { + const res = await request(app()) + .post('/compliance/kyc-attestation') + .send({ subjectAddress: CLEAN, proof: 'sig', fields: {} }); + + expect(res.status).toBe(400); + }); + + it('screens an address via POST /compliance/screen', async () => { + process.env.COMPLIANCE_DENYLIST = SANCTIONED; + + const res = await request(app()) + .post('/compliance/screen') + .send({ address: SANCTIONED }); + + expect(res.status).toBe(200); + expect(res.body.isSanctioned).toBe(true); + expect(res.body.blocked).toBe(true); + }); + + it('returns 400 when screening without an address', async () => { + const res = await request(app()).post('/compliance/screen').send({}); + expect(res.status).toBe(400); + }); + + it('screens an address via GET /compliance/screen/:address', async () => { + process.env.COMPLIANCE_DENYLIST = SANCTIONED; + + const res = await request(app()).get(`/compliance/screen/${CLEAN}`); + + expect(res.status).toBe(200); + expect(res.body.address).toBe(CLEAN); + expect(res.body.isSanctioned).toBe(false); + }); +}); diff --git a/backend/tests/sentinel-service.test.ts b/backend/tests/sentinel-service.test.ts new file mode 100644 index 00000000..189886b2 --- /dev/null +++ b/backend/tests/sentinel-service.test.ts @@ -0,0 +1,622 @@ +/** + * Sentinel anomaly detection & high-velocity drain monitoring (Issue #1469). + * + * The suite exercises the sliding-window velocity trackers, every heuristic + * rule and severity band, the alert fan-out adapter, and an end-to-end + * simulated drain attack. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Redis } from 'ioredis'; + +vi.mock('../src/logger.js', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + requestContext: {}, +})); + +import { + SENTINEL_RULES, + SentinelService, + getSentinelConfig, + type SentinelConfig, + type WithdrawalAnomalyInput, +} from '../src/services/sentinel.service.js'; +import { + RedisSlidingWindowTracker, + InMemorySlidingWindowTracker, +} from '../src/lib/redis.js'; + +const BASE_CONFIG: SentinelConfig = { + enabled: true, + velocityWindowMs: 60_000, + baselineWindowMs: 24 * 60 * 60 * 1_000, + velocitySpikeMultiplier: 4, + velocityMinEvents: 5, + multiStreamMaxStreams: 20, + multiStreamWindowLedgers: 3, + highValueThresholdUsd: 100_000, + balanceDrainPct: 80, + creationSpikeWindowMs: 5 * 60 * 1_000, + creationSpikeThreshold: 25, + zeroRunwayMaxSeconds: 60, + zeroRunwayWindowMs: 5 * 60 * 1_000, + zeroRunwayFloodThreshold: 50, + alertCooldownMs: 60_000, + retentionMs: 24 * 60 * 60 * 1_000, + threatWindowMs: 15 * 60 * 1_000, + proposalSecret: 'test-sentinel-secret', +}; + +const START = 1_700_000_000_000; + +function makeHarness( + overrides: Partial = {}, + fetchImpl?: typeof fetch, +) { + let clock = START; + const tracker = new InMemorySlidingWindowTracker(); + const service = new SentinelService( + { ...BASE_CONFIG, ...overrides }, + { tracker, now: () => clock, ...(fetchImpl ? { fetchImpl } : {}) }, + ); + return { + service, + tracker, + now: () => clock, + advance: (ms: number) => { + clock += ms; + }, + set: (ms: number) => { + clock = ms; + }, + }; +} + +function withdrawal( + overrides: Partial = {}, +): WithdrawalAnomalyInput { + return { + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + token: 'CUSDC', + amount: '10', + streamId: '1', + ledger: 100, + txHash: `tx-${Math.random().toString(36).slice(2)}`, + now: START, + ...overrides, + }; +} + +describe('SentinelService — velocity tracking & severity bands', () => { + it('stays quiet for a steady withdrawal cadence', async () => { + const { service, now, advance } = makeHarness(); + const results: unknown[] = []; + for (let i = 0; i < 10; i += 1) { + results.push( + await service.recordWithdrawal( + withdrawal({ + amount: '50', + streamId: String(i), + txHash: `steady-${i}`, + now: now(), + }), + ), + ); + // One withdrawal every ~30s: at most a couple land inside any 60s + // window, so the per-minute volume stays flat against its baseline. + advance(30_000); + } + expect(results.flat()).toHaveLength(0); + }); + + it('categorises a 5x volume spike as MEDIUM', async () => { + const { service, now } = makeHarness(); + // Seed the 24h baseline: one 100-unit withdrawal 12h ago. + await service.recordWithdrawal( + withdrawal({ amount: '100', txHash: 'baseline', now: now() - 12 * 60 * 60 * 1_000 }), + ); + + let incidents: Awaited> = []; + for (let i = 0; i < 5; i += 1) { + incidents = await service.recordWithdrawal( + withdrawal({ + amount: '0.07', + streamId: `spike-${i}`, + txHash: `spike-${i}`, + now: now(), + }), + ); + } + + const velocity = incidents.find((i) => i.ruleId === SENTINEL_RULES.VELOCITY_SPIKE); + expect(velocity).toBeDefined(); + expect(velocity?.severity).toBe('MEDIUM'); + }); + + it('categorises a 7x volume spike as HIGH', async () => { + const { service, now } = makeHarness(); + await service.recordWithdrawal( + withdrawal({ amount: '100', txHash: 'baseline', now: now() - 12 * 60 * 60 * 1_000 }), + ); + + let incidents: Awaited> = []; + for (let i = 0; i < 5; i += 1) { + incidents = await service.recordWithdrawal( + withdrawal({ amount: '0.1', streamId: `spike-${i}`, txHash: `spike-${i}`, now: now() }), + ); + } + + const velocity = incidents.find((i) => i.ruleId === SENTINEL_RULES.VELOCITY_SPIKE); + expect(velocity?.severity).toBe('HIGH'); + }); + + it('categorises an extreme volume spike as CRITICAL and signs a pause proposal', async () => { + const { service, now } = makeHarness(); + await service.recordWithdrawal( + withdrawal({ amount: '100', txHash: 'baseline', now: now() - 12 * 60 * 60 * 1_000 }), + ); + + let incidents: Awaited> = []; + for (let i = 0; i < 5; i += 1) { + incidents = await service.recordWithdrawal( + withdrawal({ amount: '0.5', streamId: `spike-${i}`, txHash: `spike-${i}`, now: now() }), + ); + } + + const velocity = incidents.find((i) => i.ruleId === SENTINEL_RULES.VELOCITY_SPIKE); + expect(velocity?.severity).toBe('CRITICAL'); + expect(velocity?.circuitBreaker).not.toBeNull(); + expect(velocity?.circuitBreaker?.action).toBe('set_emergency_pause'); + expect(velocity?.circuitBreaker?.value).toBe(true); + expect(velocity?.circuitBreaker?.payloadHash).toHaveLength(64); + expect(velocity?.circuitBreaker?.signature).toHaveLength(64); + expect(velocity?.circuitBreaker?.algorithm).toBe('hmac-sha256'); + }); + + it('isolates velocity per address', async () => { + const { service, now } = makeHarness(); + const victim = 'GVICTIMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + await service.recordWithdrawal( + withdrawal({ address: victim, amount: '100', txHash: 'baseline', now: now() - 12 * 60 * 60 * 1_000 }), + ); + + // A different wallet's burst must not be attributed to the victim. + let incidents: Awaited> = []; + for (let i = 0; i < 5; i += 1) { + incidents = await service.recordWithdrawal( + withdrawal({ + address: 'GOTHERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + amount: '10', + streamId: `x-${i}`, + txHash: `x-${i}`, + now: now(), + }), + ); + } + expect(incidents.find((i) => i.ruleId === SENTINEL_RULES.VELOCITY_SPIKE)?.address).not.toBe(victim); + }); +}); + +describe('SentinelService — drain heuristics', () => { + it('flags a rapid multi-stream drain once the distinct-stream bar is crossed', async () => { + const { service, now } = makeHarness({ + multiStreamMaxStreams: 3, + velocityMinEvents: 100, // disable the velocity rule for this case + }); + + let incidents: Awaited> = []; + for (let i = 0; i < 4; i += 1) { + incidents = await service.recordWithdrawal( + withdrawal({ streamId: `s-${i}`, txHash: `drain-${i}`, now: now() }), + ); + } + + const drain = incidents.find((i) => i.ruleId === SENTINEL_RULES.MULTI_STREAM_DRAIN); + expect(drain?.severity).toBe('HIGH'); + expect(drain?.evidence.distinctStreams).toBe(4); + }); + + it('escalates a very wide multi-stream drain to CRITICAL', async () => { + const { service, now } = makeHarness({ + multiStreamMaxStreams: 2, + velocityMinEvents: 100, + }); + + let incidents: Awaited> = []; + for (let i = 0; i < 6; i += 1) { + incidents = await service.recordWithdrawal( + withdrawal({ streamId: `s-${i}`, txHash: `wide-${i}`, now: now() }), + ); + } + expect( + incidents.find((i) => i.ruleId === SENTINEL_RULES.MULTI_STREAM_DRAIN)?.severity, + ).toBe('CRITICAL'); + }); + + it('flags a single withdrawal above the USD notional threshold', async () => { + const { service } = makeHarness({ velocityMinEvents: 100 }); + const incidents = await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 250_000, txHash: 'whale' }), + ); + + const drain = incidents.find((i) => i.ruleId === SENTINEL_RULES.HIGH_VALUE_DRAIN); + expect(drain).toBeDefined(); + expect(drain?.severity).toBe('CRITICAL'); // >= 2x the 100k threshold + }); + + it('flags a withdrawal draining >80% of the stream balance without a price feed', async () => { + const { service } = makeHarness({ velocityMinEvents: 100 }); + const incidents = await service.recordWithdrawal( + withdrawal({ + amount: '90', + streamDeposited: '100', + txHash: 'balance-drain', + }), + ); + + const drain = incidents.find((i) => i.ruleId === SENTINEL_RULES.HIGH_VALUE_DRAIN); + expect(drain).toBeDefined(); + expect(drain?.severity).toBe('HIGH'); + expect(drain?.evidence.drainedPct).toBeCloseTo(90); + }); + + it('does not flag a routine partial withdrawal', async () => { + const { service } = makeHarness({ velocityMinEvents: 100 }); + const incidents = await service.recordWithdrawal( + withdrawal({ amount: '10', streamDeposited: '1000', txHash: 'routine' }), + ); + expect(incidents).toHaveLength(0); + }); +}); + +describe('SentinelService — stream creation heuristics', () => { + it('flags a zero-runway stream flood as CRITICAL', async () => { + const { service, now } = makeHarness({ + zeroRunwayFloodThreshold: 3, + creationSpikeThreshold: 1000, + }); + + let incidents: Awaited> = []; + for (let i = 0; i < 4; i += 1) { + incidents = await service.recordStreamCreation({ + sender: 'GSENDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + token: 'CUSDC', + streamId: String(i), + runwaySeconds: 5, + ledger: 200 + i, + now: now(), + }); + } + + const flood = incidents.find((i) => i.ruleId === SENTINEL_RULES.ZERO_RUNWAY_FLOOD); + expect(flood?.severity).toBe('CRITICAL'); + expect(flood?.circuitBreaker).not.toBeNull(); + }); + + it('flags a per-token stream creation spike', async () => { + const { service, now } = makeHarness({ creationSpikeThreshold: 2 }); + + let incidents: Awaited> = []; + for (let i = 0; i < 3; i += 1) { + incidents = await service.recordStreamCreation({ + sender: 'GSENDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + token: 'CXLM', + streamId: `k-${i}`, + runwaySeconds: 86_400, + ledger: 300 + i, + now: now(), + }); + } + + const spike = incidents.find((i) => i.ruleId === SENTINEL_RULES.STREAM_CREATION_SPIKE); + expect(spike).toBeDefined(); + expect(spike?.severity).toBe('MEDIUM'); + }); +}); + +describe('SentinelService — alerting', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ status: 204, ok: true }); + }); + + it('dispatches a rich incident payload to the configured channels', async () => { + const { service } = makeHarness( + { + discordWebhookUrl: 'https://discord.test/webhook', + slackWebhookUrl: 'https://hooks.slack.test/abc', + pagerDutyRoutingKey: 'pd-routing-key', + velocityMinEvents: 100, + }, + fetchMock as unknown as typeof fetch, + ); + + const incidents = await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 500_000, txHash: 'alert-me' }), + ); + const drain = incidents.find((i) => i.ruleId === SENTINEL_RULES.HIGH_VALUE_DRAIN); + expect(drain).toBeDefined(); + + const urls = fetchMock.mock.calls.map((call) => call[0]); + expect(urls).toContain('https://discord.test/webhook'); + expect(urls).toContain('https://hooks.slack.test/abc'); + expect(urls).toContain('https://events.pagerduty.com/v2/enqueue'); + + const discordBody = JSON.parse( + String(fetchMock.mock.calls.find((c) => c[0] === 'https://discord.test/webhook')?.[1]?.body), + ); + expect(discordBody.embeds[0].title).toContain('HIGH_VALUE_DRAIN'); + expect(discordBody.sentinel.severity).toBe('CRITICAL'); + + const pdBody = JSON.parse( + String( + fetchMock.mock.calls.find((c) => c[0] === 'https://events.pagerduty.com/v2/enqueue')?.[1] + ?.body, + ), + ); + expect(pdBody.routing_key).toBe('pd-routing-key'); + expect(pdBody.event_action).toBe('trigger'); + expect(pdBody.payload.severity).toBe('critical'); + }); + + it('survives an unreachable alert endpoint', async () => { + const failing = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + const { service } = makeHarness( + { slackWebhookUrl: 'https://hooks.slack.test/down', velocityMinEvents: 100 }, + failing as unknown as typeof fetch, + ); + + const incidents = await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 500_000, txHash: 'resilient' }), + ); + expect(incidents).toHaveLength(1); + const deliveries = await service.dispatchAlert(incidents[0]!); + expect(deliveries[0]).toMatchObject({ channel: 'slack', ok: false }); + }); + + it('suppresses repeat alerts for the same rule + subject inside the cooldown', async () => { + const { service, now } = makeHarness({ + velocityMinEvents: 100, + alertCooldownMs: 300_000, + }); + + const first = await service.recordWithdrawal( + withdrawal({ amount: '90', streamDeposited: '100', streamId: '42', txHash: 'first', now: now() }), + ); + expect(first).toHaveLength(1); + + const second = await service.recordWithdrawal( + withdrawal({ amount: '95', streamDeposited: '100', streamId: '42', txHash: 'second', now: now() }), + ); + expect(second).toHaveLength(0); + + // After the cooldown the same subject can alert again. + now(); + const later = await service.recordWithdrawal( + withdrawal({ + amount: '96', + streamDeposited: '100', + streamId: '42', + txHash: 'third', + now: now() + 400_000, + }), + ); + expect(later).toHaveLength(1); + }); +}); + +describe('SentinelService — dashboard read APIs', () => { + it('exposes a threat score and ranked flagged addresses', async () => { + const { service } = makeHarness({ velocityMinEvents: 100 }); + await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 500_000, txHash: 't1' }), + ); + await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 500_000, streamId: '2', txHash: 't2' }), + ); + + const summary = service.getThreatScore(); + expect(summary.score).toBeGreaterThan(0); + expect(summary.level).not.toBe('NONE'); + expect(summary.bySeverity.CRITICAL).toBeGreaterThan(0); + + const flagged = service.getFlaggedAddresses(); + expect(flagged.length).toBeGreaterThan(0); + expect(flagged[0]?.address).toBe('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + expect(flagged[0]?.highestSeverity).toBe('CRITICAL'); + }); + + it('filters alerts by severity and acknowledges incidents', async () => { + const { service } = makeHarness({ velocityMinEvents: 100 }); + const incidents = await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 500_000, txHash: 'ack' }), + ); + + expect(service.getAlerts({ severity: 'CRITICAL' })).toHaveLength(1); + expect(service.getAlerts({ severity: 'LOW' })).toHaveLength(0); + expect(service.getAlerts({ address: 'GNOPE' })).toHaveLength(0); + + expect(service.acknowledgeAlert(incidents[0]!.id)).toBe(true); + expect(service.getAlerts()[0]?.acknowledged).toBe(true); + expect(service.acknowledgeAlert('missing-id')).toBe(false); + }); + + it('clears retained state on reset', async () => { + const { service } = makeHarness({ velocityMinEvents: 100 }); + await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 500_000, txHash: 'reset' }), + ); + expect(service.getAlerts()).toHaveLength(1); + + await service.reset(); + expect(service.getAlerts()).toHaveLength(0); + expect(service.getThreatScore().score).toBe(0); + }); +}); + +describe('SentinelService — simulated drain attack', () => { + it('raises velocity and multi-stream incidents during a coordinated drain', async () => { + const { service, now } = makeHarness({ + multiStreamMaxStreams: 10, + velocityMinEvents: 5, + }); + const attacker = 'GATTACKERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + // Establish a low-volume 24h baseline for this wallet. + await service.recordWithdrawal( + withdrawal({ address: attacker, amount: '5', txHash: 'hist', now: now() - 12 * 60 * 60 * 1_000 }), + ); + + const raised: string[] = []; + // 15 withdrawals across 15 distinct streams inside one minute. + for (let i = 0; i < 15; i += 1) { + const incidents = await service.recordWithdrawal( + withdrawal({ + address: attacker, + amount: '5000', + streamId: `victim-${i}`, + txHash: `attack-${i}`, + ledger: 1000 + i, + now: now() + i * 1_000, + }), + ); + raised.push(...incidents.map((incident) => incident.ruleId)); + } + + expect(raised).toContain(SENTINEL_RULES.VELOCITY_SPIKE); + expect(raised).toContain(SENTINEL_RULES.MULTI_STREAM_DRAIN); + + // The attacker is the top flagged address and the dashboard agrees. + expect(service.getFlaggedAddresses()[0]?.address).toBe(attacker); + expect(service.getThreatScore().level).not.toBe('NONE'); + }); +}); + +describe('SentinelService — config', () => { + it('reads thresholds from the environment with sane defaults', () => { + const original = { ...process.env }; + process.env.SENTINEL_ENABLED = 'false'; + process.env.SENTINEL_MULTI_STREAM_MAX = '7'; + delete process.env.SENTINEL_VELOCITY_SPIKE_MULTIPLIER; + + const config = getSentinelConfig(); + expect(config.enabled).toBe(false); + expect(config.multiStreamMaxStreams).toBe(7); + expect(config.velocitySpikeMultiplier).toBe(4); + + process.env = original; + }); + + it('is a no-op when disabled', async () => { + const { service } = makeHarness({ enabled: false, velocityMinEvents: 1 }); + const incidents = await service.recordWithdrawal( + withdrawal({ amount: '1', amountUsd: 999_999, txHash: 'disabled' }), + ); + expect(incidents).toHaveLength(0); + }); +}); + +// ─── Redis-backed sliding-window tracker ──────────────────────────────────── + +/** Minimal in-memory stand-in for the ioredis sorted-set commands used. */ +function createFakeRedis(): Redis { + const zsets = new Map>(); + const get = (key: string) => { + let set = zsets.get(key); + if (!set) { + set = new Map(); + zsets.set(key, set); + } + return set; + }; + + return { + zadd: async (key: string, score: number | string, member: string) => { + get(key).set(member, Number(score)); + return 1; + }, + zremrangebyscore: async (key: string, _min: string | number, max: string | number) => { + const set = get(key); + const maxValue = max === '+inf' ? Number.POSITIVE_INFINITY : Number(max); + let removed = 0; + for (const [member, score] of [...set]) { + if (score <= maxValue) { + set.delete(member); + removed += 1; + } + } + return removed; + }, + zrangebyscore: async (key: string, min: string | number, _max: string | number) => { + const set = get(key); + const minValue = typeof min === 'string' && min.startsWith('(') ? Number(min.slice(1)) : Number(min); + const out: string[] = []; + for (const [member, score] of [...set.entries()].sort((a, b) => a[1] - b[1])) { + if (score >= minValue) out.push(member, String(score)); + } + return out; + }, + zcount: async (key: string, min: string | number) => { + const minValue = typeof min === 'string' && min.startsWith('(') ? Number(min.slice(1)) : Number(min); + return [...get(key).values()].filter((score) => score >= minValue).length; + }, + pexpire: async () => 1, + del: async (key: string) => { + zsets.delete(key); + return 1; + }, + } as unknown as Redis; +} + +describe('RedisSlidingWindowTracker', () => { + it('counts events and sums weights inside the window via sorted sets', async () => { + const tracker = new RedisSlidingWindowTracker(createFakeRedis()); + await tracker.add('k', 'a', 10, 1_000); + await tracker.add('k', 'b', 20, 2_000); + await tracker.add('k', 'c', 30, 3_000); + + const snapshot = await tracker.snapshot('k', 10_000, 4_000); + expect(snapshot.count).toBe(3); + expect(snapshot.sum).toBe(60); + expect(snapshot.firstAt).toBe(1_000); + expect(snapshot.lastAt).toBe(3_000); + }); + + it('ages samples out of the window and deduplicates by id', async () => { + const tracker = new RedisSlidingWindowTracker(createFakeRedis()); + await tracker.add('k', 'old', 5, 1_000); + await tracker.add('k', 'new', 5, 9_000); + + const snapshot = await tracker.snapshot('k', 5_000, 10_000); + expect(snapshot.count).toBe(1); + expect(snapshot.sum).toBe(5); + + // Re-adding the same id refreshes the timestamp instead of duplicating. + await tracker.add('k', 'new', 5, 9_500); + expect(await tracker.distinct('k', 5_000, 10_000)).toBe(1); + }); + + it('reports distinct ids for multi-stream tracking', async () => { + const tracker = new RedisSlidingWindowTracker(createFakeRedis()); + await tracker.add('streams', 's1', 1, 1_000); + await tracker.add('streams', 's1', 1, 1_100); + await tracker.add('streams', 's2', 1, 1_200); + expect(await tracker.distinct('streams', 10_000, 2_000)).toBe(2); + + await tracker.clear('streams'); + expect(await tracker.distinct('streams', 10_000, 2_000)).toBe(0); + }); +}); + +describe('InMemorySlidingWindowTracker', () => { + it('supports the same add/snapshot/distinct contract', async () => { + const tracker = new InMemorySlidingWindowTracker(); + await tracker.add('k', 'a', 2, 1_000); + await tracker.add('k', 'b', 3, 2_000); + + expect(await tracker.snapshot('k', 5_000, 3_000)).toMatchObject({ count: 2, sum: 5 }); + expect(await tracker.distinct('k', 5_000, 3_000)).toBe(2); + // The 1s sample has aged out of a 1.5s window measured at 3s. + expect(await tracker.snapshot('k', 1_500, 3_000)).toMatchObject({ count: 1, sum: 3 }); + }); +}); diff --git a/backend/tests/sentinel.admin.test.ts b/backend/tests/sentinel.admin.test.ts new file mode 100644 index 00000000..98130110 --- /dev/null +++ b/backend/tests/sentinel.admin.test.ts @@ -0,0 +1,162 @@ +/** + * Admin sentinel API (Issue #1469). + * + * Mounts the real admin router behind a pass-through auth/rate-limit stub and + * asserts the responder-facing contract: threat score, flagged addresses and + * the incident feed. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import express, { type Request, type Response, type NextFunction } from 'express'; +import request from 'supertest'; + +vi.mock('../src/lib/prisma.js', () => ({ prisma: {}, pool: {} })); + +vi.mock('../src/logger.js', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../src/middleware/auth.js', () => ({ + requireAdmin: (req: Request, _res: Response, next: NextFunction) => { + (req as unknown as { user: { publicKey: string } }).user = { + publicKey: 'GADMINAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }; + next(); + }, +})); + +vi.mock('../src/middleware/admin-rate-limiter.middleware.js', () => ({ + adminRateLimiter: (_req: Request, _res: Response, next: NextFunction) => next(), +})); + +vi.mock('../src/services/sse.service.js', () => ({ + sseService: { getClientCount: () => 0, broadcastToAdmin: vi.fn() }, +})); + +vi.mock('../src/workers/soroban-event-worker.js', () => ({ + sorobanEventWorker: { + getEventCounters: () => ({ + eventsProcessed: 0, + eventsFailed: 0, + lastErrorAt: null, + degraded: false, + }), + }, +})); + +vi.mock('../src/services/indexerService.js', () => ({ + getIndexerStatus: vi.fn(), + resetIndexer: vi.fn(), + replayFromLedger: vi.fn(), + previewReset: vi.fn(), + previewReplay: vi.fn(), + listDeadLetterEvents: vi.fn(), + replayDeadLetterEvent: vi.fn(), + replayAllDeadLetterEvents: vi.fn(), + discardDeadLetterEvent: vi.fn(), + DeadLetterNotFoundError: class DeadLetterNotFoundError extends Error {}, + DEFAULT_DEAD_LETTER_PAGE_SIZE: 25, + MAX_DEAD_LETTER_PAGE_SIZE: 100, +})); + +import adminRoutes from '../src/routes/v1/admin.routes.js'; +import { getSentinelService } from '../src/services/sentinel.service.js'; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/v1/admin', adminRoutes); + return app; +} + +const ATTACKER = 'GATTACKERADMINAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +async function raiseCritical(labels: { streamId?: string; txHash: string }) { + return getSentinelService().recordWithdrawal({ + address: ATTACKER, + token: 'CUSDC', + amount: '1', + amountUsd: 500_000, + streamId: labels.streamId ?? '77', + ledger: 9_001, + txHash: labels.txHash, + }); +} + +describe('GET /v1/admin/sentinel/alerts', () => { + beforeEach(async () => { + await getSentinelService().reset(); + }); + + it('returns the threat score, flagged addresses and incident feed', async () => { + await raiseCritical({ txHash: 'admin-1' }); + + const res = await request(buildApp()).get('/v1/admin/sentinel/alerts'); + expect(res.status).toBe(200); + + expect(res.body.threatScore.score).toBeGreaterThan(0); + expect(res.body.threatScore.level).not.toBe('NONE'); + expect(res.body.flaggedAddresses[0]).toMatchObject({ + address: ATTACKER, + highestSeverity: 'CRITICAL', + }); + + expect(res.body.count).toBeGreaterThan(0); + expect(res.body.incidents[0]).toMatchObject({ + ruleId: 'HIGH_VALUE_DRAIN', + severity: 'CRITICAL', + address: ATTACKER, + }); + expect(res.body.incidents[0].circuitBreaker.action).toBe('set_emergency_pause'); + expect(typeof res.body.generatedAt).toBe('string'); + }); + + it('filters by severity and rejects an unknown severity', async () => { + await raiseCritical({ txHash: 'admin-2' }); + + const critical = await request(buildApp()).get( + '/v1/admin/sentinel/alerts?severity=critical', + ); + expect(critical.status).toBe(200); + expect(critical.body.count).toBeGreaterThan(0); + + const low = await request(buildApp()).get('/v1/admin/sentinel/alerts?severity=LOW'); + expect(low.status).toBe(200); + expect(low.body.count).toBe(0); + + const invalid = await request(buildApp()).get( + '/v1/admin/sentinel/alerts?severity=BANANA', + ); + expect(invalid.status).toBe(400); + }); + + it('respects the limit parameter', async () => { + await raiseCritical({ txHash: 'admin-3', streamId: '1' }); + await raiseCritical({ txHash: 'admin-4', streamId: '2' }); + + const res = await request(buildApp()).get('/v1/admin/sentinel/alerts?limit=1'); + expect(res.status).toBe(200); + expect(res.body.incidents).toHaveLength(1); + }); +}); + +describe('POST /v1/admin/sentinel/alerts/:id/acknowledge', () => { + beforeEach(async () => { + await getSentinelService().reset(); + }); + + it('acknowledges a known incident and 404s an unknown one', async () => { + const [incident] = await raiseCritical({ txHash: 'admin-ack' }); + expect(incident).toBeDefined(); + + const ok = await request(buildApp()).post( + `/v1/admin/sentinel/alerts/${incident!.id}/acknowledge`, + ); + expect(ok.status).toBe(200); + expect(ok.body).toMatchObject({ ok: true, acknowledged: true }); + + const missing = await request(buildApp()).post( + '/v1/admin/sentinel/alerts/does-not-exist/acknowledge', + ); + expect(missing.status).toBe(404); + }); +}); diff --git a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx index 745556cb..aaa26a74 100644 --- a/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx +++ b/frontend/src/app/streams/[id]/__tests__/stream-details-content.test.tsx @@ -63,6 +63,15 @@ vi.mock("@/lib/api/_shared", () => ({ getApiBaseUrl: () => "http://localhost:4000", })); +// The component reads the token price through react-query. This suite covers UI +// behaviour, not the price feed, so stub the hook instead of wiring up a +// QueryClientProvider (and a network layer) just to render. +vi.mock("@/hooks/useTokenPrice", () => ({ + useTokenPrice: () => ({ data: undefined }), + convertToFiat: () => 0, + formatFiatAmount: () => "$0.00", +})); + vi.mock("@/lib/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); @@ -155,6 +164,14 @@ function createMockStream() { describe("StreamDetailsContent loading skeleton", () => { beforeEach(() => { vi.clearAllMocks(); + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); origUseWallet.mockReturnValue({ session: mockSession, @@ -210,6 +227,9 @@ describe("StreamDetailsContent loading skeleton", () => { // Skeleton should be gone expect(screen.queryByRole("status")).not.toBeInTheDocument(); + // Stream-specific content should be visible (the header and receipt rows + // both reference the stream id, so assert on all matches). + expect(screen.getAllByText(/stream #42/i).length).toBeGreaterThan(0); // Stream-specific content should be visible expect(screen.getAllByText(/stream #42/i).length).toBeGreaterThanOrEqual(1); }); @@ -336,6 +356,14 @@ describe("StreamDetailsContent handleWithdraw", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); mockWalletForRecipient(); }); @@ -385,6 +413,14 @@ describe("StreamDetailsContent handleTopUp", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); // TopUp is only visible for the sender origUseWallet.mockReturnValue({ @@ -456,6 +492,14 @@ describe("StreamDetailsContent handlePause", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); origUseWallet.mockReturnValue({ session: mockSession, @@ -498,6 +542,14 @@ describe("StreamDetailsContent handleResume", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); origUseWallet.mockReturnValue({ session: mockSession, @@ -540,6 +592,14 @@ describe("StreamDetailsContent handleCancel", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); origUseWallet.mockReturnValue({ session: mockSession, @@ -587,6 +647,14 @@ describe("StreamDetailsContent live-claimable interval", () => { beforeEach(() => { vi.clearAllMocks(); mockTracker.status = "idle"; + // Default to a benign empty response so the component's background fetches + // (events, receipt tx hash) resolve; individual tests layer `mockResolvedValueOnce`. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ events: [], total: 0 }), + } as Response) + ); global.fetch = createFetchMock(); }); diff --git a/frontend/src/lib/api-types.generated.ts b/frontend/src/lib/api-types.generated.ts index f22c9aad..04d0e9f6 100644 --- a/frontend/src/lib/api-types.generated.ts +++ b/frontend/src/lib/api-types.generated.ts @@ -2204,6 +2204,210 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/compliance/kyc-attestation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Submit a SEP-0009 KYC/AML attestation + * @description Allows an organization to submit a cryptographic proof of identity + * verification for a wallet, following the SEP-0009 (Standard KYC / AML + * Fields) schema. Attestations are stored as PENDING for out-of-band + * review and are recorded in the compliance audit trail. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Stellar public key the identity belongs to */ + subjectAddress: string; + /** @description Organization identifier; defaults to the authenticated wallet */ + organization?: string; + /** @description Cryptographic proof / signature over the identity payload */ + proof: string; + /** @enum {string} */ + proofType?: "ed25519" | "secp256k1" | "stellar-signature"; + /** @description SEP-0009 KYC/AML fields (snake_case, vendor fields passthrough) */ + fields: Record; + }; + }; + }; + responses: { + /** @description Attestation accepted for review */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid attestation payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized - missing or invalid authentication */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/compliance/screen": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Screen an address against sanctions data + * @description Runs the configured sanctions / risk screening provider for a wallet + * address and returns the result. Results are cached for the configured + * TTL. Requires authentication to prevent open enumeration. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Stellar public key to screen */ + address: string; + }; + }; + }; + responses: { + /** @description Screening result */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request body */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized - missing or invalid authentication */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Screening provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/compliance/screen/{address}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Screen a wallet address (read-only) + * @description Convenience GET variant of /v1/compliance/screen for audit tooling. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Stellar public key to screen */ + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Screening result */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Address is required */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized - missing or invalid authentication */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Screening provider unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/auth/challenge": { parameters: { query?: never; @@ -2926,6 +3130,141 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/admin/sentinel/alerts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List sentinel anomalies, threat score, and flagged addresses + * @description Returns the real-time anomaly dashboard backing the drain sentinel: + * the aggregate 0-100 threat score, the addresses implicated in recent + * anomalies, and the incident history (newest first). Filter by + * `severity`, `address`, or page with `limit`. + */ + get: { + parameters: { + query?: { + severity?: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + /** @description Stellar public key to filter incidents by */ + address?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Sentinel dashboard snapshot */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + threatScore?: { + /** @example 75 */ + score?: number; + /** @enum {string} */ + level?: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + incidentCount?: number; + bySeverity?: { + [key: string]: number; + }; + windowMinutes?: number; + }; + flaggedAddresses?: { + address?: string; + threatScore?: number; + incidentCount?: number; + highestSeverity?: string; + /** Format: date-time */ + lastIncidentAt?: string; + }[]; + incidents?: components["schemas"]["SentinelIncident"][]; + count?: number; + /** Format: date-time */ + generatedAt?: string; + }; + }; + }; + /** @description Invalid severity filter */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized - missing or invalid authentication token */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden - admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/sentinel/alerts/{id}/acknowledge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Acknowledge a sentinel incident */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Incident acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Incident not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -3230,6 +3569,33 @@ export interface components { /** Format: date-time */ createdAt: string; }; + SentinelIncident: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + ruleId: "VELOCITY_SPIKE" | "MULTI_STREAM_DRAIN" | "HIGH_VALUE_DRAIN" | "TOKEN_VELOCITY_SPIKE" | "ZERO_RUNWAY_FLOOD" | "STREAM_CREATION_SPIKE"; + /** @enum {string} */ + severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; + title: string; + description: string; + /** @description Stellar public key involved */ + address?: string | null; + token?: string | null; + streamId?: string | null; + ledger?: number | null; + /** Format: date-time */ + detectedAt: string; + /** @description Per-incident severity weight (10-90) */ + threatScore: number; + evidence?: { + [key: string]: unknown; + }; + /** @description HMAC-signed emergency pause proposal (CRITICAL incidents only) */ + circuitBreaker?: { + [key: string]: unknown; + } | null; + acknowledged?: boolean; + }; HealthResponse: { /** * @example ok diff --git a/frontend/src/lib/transaction-feedback.ts b/frontend/src/lib/transaction-feedback.ts index 637802bf..4b1ffb06 100644 --- a/frontend/src/lib/transaction-feedback.ts +++ b/frontend/src/lib/transaction-feedback.ts @@ -47,6 +47,8 @@ export function transactionSuccessToast( options?: ToastOptions ): string { playTransactionSuccessSound(); + // Pass options only when present: `toast.success(message, undefined)` would + // still be a two-argument call and defeats option-less callers/tests. // Only forward options when they exist: `toast.success(msg, undefined)` is // equivalent at runtime, but the explicit second argument breaks callers // (and tests) that match against the single-argument call.