diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 00000000..c5b7a101 --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,217 @@ +# Backend Server Configuration +# Copy this to .env and customize for your environment + +# ── Tenant API Key Authentication ───────────────────────────────────────────── +# Per-tenant API keys are stored hashed in the `apiKey` table with scopes, +# expiry, and last-used tracking. Rotate keys per tenant without downtime. +# API_KEY below is a deprecated global fallback; it only applies when no +# tenant-scoped key matches and logs a deprecation warning on each use. +# API_KEY= + +# Server Configuration +PORT=3000 +NODE_ENV=development + +# Rate Limiting Configuration +# Global rate limiter: 100 requests per 15 minutes (900000 ms) +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# Deprecated: single global API key fallback (see Tenant API Key Authentication) +# API_KEY= + +# API endpoint rate limiter: 30 requests per minute (60000 ms) +API_RATE_LIMIT_WINDOW_MS=60000 +API_RATE_LIMIT_MAX_REQUESTS=30 + +# ── Payload Size Limits ─────────────────────────────────────────────────────── +# Route-tier-specific caps enforced by the payloadLimit middleware. +# Express size notation applies: "4kb", "16kb", "32kb", "1mb" etc. +# Omit a variable (or leave it blank) to use the compiled-in default. +# +# Tier Default Routes +# global 1mb All routes (last-resort baseline) +# auth 4kb /api/v1/auth/* (credentials/tokens only) +# admin 16kb /admin/* (structured config payloads) +# writes 32kb POST /api/v1/vault/deposits|withdrawals|deposits/v2|strategy +#PAYLOAD_LIMIT_GLOBAL=1mb +#PAYLOAD_LIMIT_AUTH=4kb +#PAYLOAD_LIMIT_ADMIN=16kb +#PAYLOAD_LIMIT_WRITES=32kb + +# ── Tenant API Key Scopes ───────────────────────────────────────────────────── +# Comma-separated list of scopes required per route tier. Requests must present +# an x-api-key whose tenant record includes the required scope, otherwise the +# middleware returns 401 SCOPE_INSUFFICIENT with a WWW-Authenticate header. +# API_KEY_SCOPES_READ=read +# API_KEY_SCOPES_WRITE=write +# API_KEY_SCOPES_ADMIN=admin +# API_KEY_EXPIRED_ERROR=API_KEY_EXPIRED +# API_KEY_SCOPE_ERROR=SCOPE_INSUFFICIENT + +# ── Wallet signed-action nonces ─────────────────────────────────────────────── +# WALLET_NONCE_TTL_SECONDS=300 +# WALLET_NONCE_MAX_ACTIVE_PER_WALLET=10 +# WALLET_NONCE_ENFORCEMENT=strict # strict | off (default: strict in production) +# WALLET_SIGNATURE_MODE=stellar # stellar | hmac (default: stellar in production) +# WALLET_ACTION_HMAC_SECRET= # HMAC secret when WALLET_SIGNATURE_MODE=hmac +# Multi-instance production deployments must set REDIS_URL below so nonce +# allocation/consumption is atomic across every backend replica. + +# ── Admin API Key RBAC ──────────────────────────────────────────────────────── +# Roles (least → most privileged): viewer, operator, admin, super-admin +# Assign when registering keys via POST /admin/api-keys/register { "role": "..." } +# viewer – read-only admin endpoints +# operator – maintenance, cache, allowlist, webhooks (non-secret), jobs, exports +# admin – full admin except impersonation / global idempotency flush +# super-admin – all privileged operations + +# Stellar RPC Configuration +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +STELLAR_NETWORK=testnet +STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +# Vault Configuration +VAULT_CONTRACT_ID= + +# PostgreSQL Database Configuration +# back to the primary when it is omitted or unavailable. +DATABASE_REPLICA_URL= +DATABASE_POOL_SIZE=10 +DATABASE_CONNECTION_TIMEOUT_MS=5000 +DATABASE_IDLE_TIMEOUT_MS=30000 +PRISMA_POOL_MAX=10 +PRISMA_POOL_TIMEOUT_MS=10000 +PRISMA_QUERY_TIMEOUT_MS=5000 + +# Admin audit log persistence mode: memory | prisma | hybrid +ADMIN_AUDIT_LOG_STORAGE=hybrid + +# Webhook verification +WEBHOOK_VERIFICATION_TIMEOUT_MS=5000 +WEBHOOK_CHALLENGE_TTL_SECONDS=900 +WEBHOOK_ALLOW_UNVERIFIED=false +WEBHOOK_SIGNATURE_MAX_SKEW_MS=300000 + +# Prisma runtime connection settings +PRISMA_POOL_SIZE=10 +PRISMA_POOL_TIMEOUT_SEC=10 +PRISMA_QUERY_TIMEOUT_MS=5000 +PRISMA_TX_MAX_WAIT_MS=5000 +PRISMA_TX_TIMEOUT_MS=10000 + +# ── Query performance budgets and slow-query alerts ────────────────────────── +# Per-query defaults; exact model/action overrides use a JSON object. +# QUERY_READ_BUDGET_MS=100 +# QUERY_WRITE_BUDGET_MS=200 +# QUERY_BUDGETS_JSON={"User.findUnique":50,"Transaction.findMany":150} +# A breach at or above this multiple of its budget is critical. +# SLOW_QUERY_CRITICAL_MULTIPLIER=3 +# SLOW_QUERY_ALERT_COOLDOWN_MS=900000 +# SLOW_QUERY_ALERT_TIMEOUT_MS=5000 +# QUERY_ALERT_TYPE=both # slack | pagerduty | both +# SLOW_QUERY_SLACK_WEBHOOK_URL= # falls back to SLACK_WEBHOOK_URL +# SLOW_QUERY_PAGERDUTY_INTEGRATION_KEY= # falls back to PAGERDUTY_INTEGRATION_KEY +# REDIS_URL below shares alert cooldowns across backend replicas. + +# ── Cache Configuration ─────────────────────────────────────────────────────── +# Redis-backed response cache for price and vault summary endpoints. +# When REDIS_URL is set, GET responses for /api/v1/vault/summary, /api/v1/vault/apy, +# and /api/v1/vault/metrics are cached in Redis so they survive process restarts and +# are shared across multiple backend replicas. +# When REDIS_URL is absent or Redis is temporarily unreachable the backend falls +# back transparently to the in-memory LRU store (CACHE_MAX_ENTRIES entries). + +# Redis connection URL (also used for rate-limiting; set once for both) +REDIS_URL=redis://localhost:6379 + +# Response cache TTL in milliseconds (applies to vault summary, metrics, APY endpoints) +CACHE_TTL_MS=60000 + +# Alias accepted in addition to CACHE_TTL_MS +# CACHE_VAULT_METRICS_TTL_MS=60000 + +# Maximum entries kept in the in-memory LRU fallback store +CACHE_MAX_ENTRIES=500 + +# Redis cache key namespace prefix (default: "cache:") +# REDIS_CACHE_KEY_PREFIX=cache: + +# Connection attempt timeout for the Redis cache client (ms) +# REDIS_CACHE_CONNECT_TIMEOUT_MS=2000 + +# Per-command timeout for Redis cache operations (ms) +# REDIS_CACHE_COMMAND_TIMEOUT_MS=500 + +# CORS Configuration +# Comma-separated list of allowed origins. Supports strings or regex like /https?:\/\/.*\.yieldvault\.finance/ +CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.yieldvault.finance + +# Email Notification Configuration +EMAIL_PROVIDER=resend +EMAIL_API_KEY= +EMAIL_FROM_ADDRESS=notifications@yieldvault.finance + +# Latency SLO Monitoring Configuration +# SLO thresholds in milliseconds +SLO_READ_THRESHOLD_MS=200 +SLO_WRITE_THRESHOLD_MS=500 + +# Prometheus /metrics endpoint scrape credential +# When set, GET /metrics requires Authorization: Bearer or ApiKey +# METRICS_AUTH_TOKEN=your-scrape-token +# METRICS_SCRAPE_TOKEN=your-scrape-token +# PROMETHEUS_BEARER_TOKEN=your-scrape-token + +# Evaluation window for P95 calculation (5 minutes = 300000 ms) +SLO_EVALUATION_WINDOW_MS=300000 + +# Alert cooldown period to prevent spam (15 minutes = 900000 ms) +SLO_ALERT_COOLDOWN_MS=900000 + +# SLO check interval (1 minute = 60000 ms) +SLO_CHECK_INTERVAL_MS=60000 + +# Alert Integration Configuration +# Set to 'slack', 'pagerduty', or 'both' +ALERT_TYPE=slack + +# Slack Webhook URL (required if ALERT_TYPE includes 'slack') +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK + +# PagerDuty Integration Key (required if ALERT_TYPE includes 'pagerduty') +PAGERDUTY_INTEGRATION_KEY= + +# Event Polling Configuration +# Poll interval for checking new events (10 seconds = 10000 ms) +EVENT_POLL_INTERVAL_MS=10000 + +# Batch size for event replay (number of ledgers per batch) +EVENT_REPLAY_BATCH_SIZE=100 + +# ── Outbox Pattern Configuration ─────────────────────────────────────────────── +# The outbox pattern ensures reliable, at-least-once event delivery by writing +# events to the EventOutbox table as part of business transactions, with a +# background processor that relays them to webhook consumers. +# +# Poll interval for the outbox processor (2 seconds = 2000 ms) +# OUTBOX_POLL_INTERVAL_MS=2000 +# +# Maximum number of entries to process per poll cycle +# OUTBOX_BATCH_SIZE=50 +# +# Lock timeout for distributed instance-level locking (60 seconds = 60000 ms) +# OUTBOX_LOCK_TIMEOUT_MS=60000 +# +# Maximum delivery attempts before moving to dead-letter +# OUTBOX_MAX_ATTEMPTS=3 +# +# Retention period for processed entries (7 days = 604800000 ms) +# OUTBOX_RETENTION_MS=604800000 +# +# Unique instance identifier for distributed locking (auto-generated if unset) +# OUTBOX_INSTANCE_ID= +# +# Cleanup interval for removing old processed entries (1 hour = 3600000 ms) +# OUTBOX_CLEANUP_INTERVAL_MS=3600000 +DATABASE_URL=file:./prisma/dev.db diff --git a/backend/jest.config.js b/backend/jest.config.js index 6817fc76..b1f052eb 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -43,6 +43,10 @@ module.exports = { '^@yieldvault/api-schemas$': '/../packages/api-schemas/dist/index.js', '^@yieldvault/api-schemas/(.*)$': '/../packages/api-schemas/dist/$1', '^@prisma/instrumentation$': '/src/__tests__/mocks/prismainstrumentation.js', + // pnpm does not hoist dotfile packages (.prisma/client) to the top-level + // node_modules, so Jest cannot resolve it with its default resolver. + // Map it explicitly to the generated location in the pnpm content store. + '^\\.prisma/client$': '/../node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/index.js', '^@opentelemetry/(.*)$': '/src/__tests__/mocks/opentelemetry.js', // Redirect the sorobanClient module that vaultEndpoints imports (./sorobanClient) // so existing integration tests keep passing without a live Stellar RPC. diff --git a/backend/src/__tests__/alertingAndGuardrails.test.ts b/backend/src/__tests__/alertingAndGuardrails.test.ts new file mode 100644 index 00000000..59268c28 --- /dev/null +++ b/backend/src/__tests__/alertingAndGuardrails.test.ts @@ -0,0 +1,331 @@ +/** + * Tests for alerting.ts and exposureGuardrails.ts (Issue #1373). + * + * Key focus: the Logger interface used by both modules must include `error` + * and `warn`. These tests inject a mock that only exposes those four methods, + * so TypeScript would reject the mock at compile-time if either method were + * missing from the interface. + */ + +import { createAlertingService } from '../alerting'; +import type { AlertChannel, AlertEvent, AlertingService } from '../alerting'; +import type { Logger } from '../middleware/structuredLogging'; +import { createExposureGuardrails, DEFAULT_RISK_CONFIG } from '../exposureGuardrails'; +import type { ProtocolRiskConfig } from '../exposureGuardrails'; + +// ─── Shared mock factory ────────────────────────────────────────────────────── + +/** Returns a Logger mock that satisfies the full interface (all four methods). */ +function makeMockLogger(): jest.Mocked { + return { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; +} + +// ─── alerting.ts ───────────────────────────────────────────────────────────── + +describe('alerting — Logger interface', () => { + it('Logger interface must include warn and error (compile-time check)', () => { + // If Logger were missing warn/error, TypeScript would fail here. + const log: Logger = makeMockLogger(); + const svc = createAlertingService([], log); + expect(svc).toBeDefined(); + }); +}); + +describe('alerting — raise()', () => { + let log: jest.Mocked; + let channel: jest.Mocked; + let svc: AlertingService; + + beforeEach(() => { + log = makeMockLogger(); + channel = { send: jest.fn().mockResolvedValue(undefined) }; + svc = createAlertingService([channel], log); + }); + + it('calls logger.error for critical severity', async () => { + await svc.raise({ name: 'tvl_breach', severity: 'critical', value: 1_000, threshold: 500 }); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).toHaveBeenCalledWith( + expect.stringContaining('Critical'), + expect.objectContaining({ alertName: 'tvl_breach', severity: 'critical' }), + ); + }); + + it('calls logger.warn for warning severity', async () => { + await svc.raise({ name: 'conc_warning', severity: 'warning', value: 80, threshold: 100 }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.error).not.toHaveBeenCalled(); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('Warning'), + expect.objectContaining({ alertName: 'conc_warning', severity: 'warning' }), + ); + }); + + it('delivers the event to every registered channel', async () => { + const channel2: jest.Mocked = { send: jest.fn().mockResolvedValue(undefined) }; + const svc2 = createAlertingService([channel, channel2], log); + const event: AlertEvent = { name: 'test', severity: 'warning', value: 1, threshold: 2 }; + await svc2.raise(event); + expect(channel.send).toHaveBeenCalledWith(event); + expect(channel2.send).toHaveBeenCalledWith(event); + }); + + it('logs a channel delivery failure with logger.error and does not throw', async () => { + channel.send.mockRejectedValueOnce(new Error('network timeout')); + await expect( + svc.raise({ name: 'net_fail', severity: 'warning', value: 1, threshold: 10 }), + ).resolves.toBeUndefined(); + // First call: the warn for the alert itself; second call: the error for delivery failure + expect(log.error).toHaveBeenCalledWith( + expect.stringContaining('delivery failed'), + expect.objectContaining({ alertName: 'net_fail', error: 'network timeout' }), + ); + }); + + it('passes metadata fields through to the logger', async () => { + await svc.raise({ + name: 'meta_test', + severity: 'critical', + value: 999, + threshold: 100, + metadata: { vaultId: 'vault-1' }, + }); + expect(log.error).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ vaultId: 'vault-1' }), + ); + }); +}); + +describe('alerting — evaluate()', () => { + let log: jest.Mocked; + let svc: AlertingService; + + beforeEach(() => { + log = makeMockLogger(); + svc = createAlertingService([], log); + }); + + it('does not alert when value is below warn ratio', async () => { + await svc.evaluate('check', 50, 100, { warnRatio: 0.8 }); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('fires warning when value is at warn ratio threshold', async () => { + await svc.evaluate('check', 80, 100, { warnRatio: 0.8, critRatio: 1.0 }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('fires critical when value equals threshold', async () => { + await svc.evaluate('check', 100, 100, { warnRatio: 0.8, critRatio: 1.0 }); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('fires critical when value exceeds threshold', async () => { + await svc.evaluate('check', 150, 100, { critRatio: 1.0 }); + expect(log.error).toHaveBeenCalledTimes(1); + }); + + it('uses default warn ratio of 0.8 and crit ratio of 1.0', async () => { + // 79 < 80 (0.8 * 100) — no alert + await svc.evaluate('check', 79, 100); + expect(log.warn).not.toHaveBeenCalled(); + // 80 >= 80 — warning + await svc.evaluate('check', 80, 100); + expect(log.warn).toHaveBeenCalledTimes(1); + // 100 >= 100 — critical + await svc.evaluate('check', 100, 100); + expect(log.error).toHaveBeenCalledTimes(1); + }); +}); + +// ─── exposureGuardrails.ts ──────────────────────────────────────────────────── + +describe('exposureGuardrails — Logger interface', () => { + it('Logger interface must include warn and error (compile-time check)', () => { + const log: Logger = makeMockLogger(); + const g = createExposureGuardrails(DEFAULT_RISK_CONFIG, log); + expect(g).toBeDefined(); + }); +}); + +describe('exposureGuardrails — checkDeposit()', () => { + let log: jest.Mocked; + + function makeGuardrails(cfg?: Partial) { + return createExposureGuardrails({ ...DEFAULT_RISK_CONFIG, ...cfg }, log); + } + + beforeEach(() => { + log = makeMockLogger(); + }); + + it('allows deposit when TVL cap is unlimited (0)', () => { + const g = makeGuardrails({ maxVaultTvl: 0n }); + const result = g.checkDeposit({ currentTvl: 1_000_000n, depositAmount: 999_999_999n }); + expect(result.outcome).toBe('ok'); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('allows deposit that stays within the cap', () => { + const g = makeGuardrails({ maxVaultTvl: 1_000_000n }); + const result = g.checkDeposit({ currentTvl: 500_000n, depositAmount: 499_999n }); + expect(result.outcome).toBe('ok'); + }); + + it('allows deposit that lands exactly on the cap', () => { + const g = makeGuardrails({ maxVaultTvl: 1_000_000n }); + const result = g.checkDeposit({ currentTvl: 500_000n, depositAmount: 500_000n }); + expect(result.outcome).toBe('ok'); + }); + + it('rejects deposit that would exceed TVL cap', () => { + const g = makeGuardrails({ maxVaultTvl: 1_000_000n }); + const result = g.checkDeposit({ currentTvl: 900_000n, depositAmount: 200_000n }); + expect(result.outcome).toBe('rejected'); + expect(result.reason).toBeDefined(); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('TVL cap exceeded'), + expect.any(Object), + ); + }); +}); + +describe('exposureGuardrails — checkInvest()', () => { + let log: jest.Mocked; + + function makeGuardrails(cfg?: Partial) { + return createExposureGuardrails({ ...DEFAULT_RISK_CONFIG, ...cfg }, log); + } + + beforeEach(() => { + log = makeMockLogger(); + }); + + it('rejects when TVL is zero', () => { + const g = makeGuardrails(); + const result = g.checkInvest({ + totalTvl: 0n, + currentStrategyAmount: 0n, + investAmount: 100n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('rejected'); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('allows invest within concentration and deployed caps', () => { + // TVL = 1_000_000; strategy gets 300_000 (30%); deployed total = 300_000 (30%) + const g = makeGuardrails({ maxConcBps: 5_000, maxDeployedBps: 5_000 }); + const result = g.checkInvest({ + totalTvl: 1_000_000n, + currentStrategyAmount: 0n, + investAmount: 300_000n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('ok'); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('rejects when concentration cap is exceeded', () => { + // Cap 5_000 bps = 50%. Investing 600_000 of 1_000_000 TVL = 60%. + const g = makeGuardrails({ maxConcBps: 5_000, maxDeployedBps: 10_000 }); + const result = g.checkInvest({ + totalTvl: 1_000_000n, + currentStrategyAmount: 0n, + investAmount: 600_000n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('rejected'); + expect(result.reason).toMatch(/concentration/i); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('rejects when deployed capital cap is exceeded', () => { + // Conc cap fine; deployed cap 5_000 bps = 50%. Deploying 600_000 = 60%. + const g = makeGuardrails({ maxConcBps: 10_000, maxDeployedBps: 5_000 }); + const result = g.checkInvest({ + totalTvl: 1_000_000n, + currentStrategyAmount: 0n, + investAmount: 600_000n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('rejected'); + expect(result.reason).toMatch(/deployed capital/i); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + describe('stress mode', () => { + it('isStressMode returns true when configured', () => { + const g = makeGuardrails({ stressMode: true }); + expect(g.isStressMode).toBe(true); + }); + + it('applies tighter stress caps when stressMode is true', () => { + // Normal conc cap: 100% (10_000 bps). Stress cap: 30% (3_000 bps). + // Invest 400_000 / 1_000_000 TVL = 40% → rejected under stress. + const g = makeGuardrails({ + maxConcBps: 10_000, + maxDeployedBps: 10_000, + stressConcBps: 3_000, + stressDeployedBps: 10_000, + stressMode: true, + }); + const result = g.checkInvest({ + totalTvl: 1_000_000n, + currentStrategyAmount: 0n, + investAmount: 400_000n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('rejected'); + expect(result.reason).toMatch(/stress-mode/); + }); + + it('allows the same invest when stress mode is off', () => { + const g = makeGuardrails({ + maxConcBps: 10_000, + maxDeployedBps: 10_000, + stressConcBps: 3_000, + stressDeployedBps: 10_000, + stressMode: false, + }); + const result = g.checkInvest({ + totalTvl: 1_000_000n, + currentStrategyAmount: 0n, + investAmount: 400_000n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('ok'); + }); + + it('uses the tighter of normal and stress deployed caps', () => { + // stressDeployedBps (70%) is tighter than maxDeployedBps (100%). + // Invest 800_000 / 1_000_000 = 80% → rejected. + const g = makeGuardrails({ + maxConcBps: 10_000, + maxDeployedBps: 10_000, + stressConcBps: 10_000, + stressDeployedBps: 7_000, + stressMode: true, + }); + const result = g.checkInvest({ + totalTvl: 1_000_000n, + currentStrategyAmount: 0n, + investAmount: 800_000n, + totalDeployedAmount: 0n, + }); + expect(result.outcome).toBe('rejected'); + expect(result.reason).toMatch(/deployed capital/i); + }); + }); +}); diff --git a/backend/src/alerting.ts b/backend/src/alerting.ts new file mode 100644 index 00000000..8082de72 --- /dev/null +++ b/backend/src/alerting.ts @@ -0,0 +1,154 @@ +/** + * @file alerting.ts + * Protocol-level alerting for threshold breaches and critical events. + * + * Centralises alert routing so latency monitoring, exposure guardrails, and + * other subsystems have a single, consistent way to raise an alert. The + * module is intentionally thin: it owns threshold comparison and logging but + * delegates notification delivery to the caller-supplied channels. + * + * Issue #1373 — the Logger type passed to createAlertingService must include + * `error` and `warn`. The `Logger` interface exported from + * `middleware/structuredLogging` satisfies that requirement. + */ + +import type { Logger } from './middleware/structuredLogging'; +import { logger as defaultLogger } from './middleware/structuredLogging'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type AlertSeverity = 'warning' | 'critical'; + +/** Describes a single threshold breach that should be surfaced to operators. */ +export interface AlertEvent { + /** Human-readable identifier for the check that fired. */ + name: string; + severity: AlertSeverity; + /** Observed value that exceeded the threshold. */ + value: number; + /** The threshold that was breached. */ + threshold: number; + /** Additional structured context for the alert. */ + metadata?: Record; +} + +/** Delivery channel that receives formatted alerts. */ +export interface AlertChannel { + send(event: AlertEvent): Promise; +} + +/** Public surface of the alerting service. */ +export interface AlertingService { + /** + * Evaluate `value` against `threshold`. Fires a 'warning' when + * `warnRatio` is exceeded and a 'critical' alert when `critRatio` is + * exceeded (both ratios relative to `threshold`). + */ + evaluate( + name: string, + value: number, + threshold: number, + options?: EvaluateOptions, + ): Promise; + + /** Raise an alert unconditionally. */ + raise(event: AlertEvent): Promise; +} + +export interface EvaluateOptions { + /** Fraction of threshold at which a 'warning' fires (default 0.8). */ + warnRatio?: number; + /** Fraction of threshold at which a 'critical' fires (default 1.0). */ + critRatio?: number; + /** Extra metadata to attach to the fired event. */ + metadata?: Record; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +class AlertingServiceImpl implements AlertingService { + private readonly log: Logger; + private readonly channels: AlertChannel[]; + + constructor(log: Logger, channels: AlertChannel[]) { + this.log = log; + this.channels = channels; + } + + async evaluate( + name: string, + value: number, + threshold: number, + options: EvaluateOptions = {}, + ): Promise { + const { warnRatio = 0.8, critRatio = 1.0, metadata } = options; + + const warnThreshold = threshold * warnRatio; + const critThreshold = threshold * critRatio; + + if (value >= critThreshold) { + await this.raise({ + name, + severity: 'critical', + value, + threshold, + metadata, + }); + } else if (value >= warnThreshold) { + await this.raise({ + name, + severity: 'warning', + value, + threshold, + metadata, + }); + } + } + + async raise(event: AlertEvent): Promise { + const logFields = { + alertName: event.name, + severity: event.severity, + value: event.value, + threshold: event.threshold, + ...event.metadata, + }; + + if (event.severity === 'critical') { + this.log.error(`[alerting] Critical threshold breach: ${event.name}`, logFields); + } else { + this.log.warn(`[alerting] Warning threshold breach: ${event.name}`, logFields); + } + + const deliveries = this.channels.map((ch) => + ch.send(event).catch((err: unknown) => { + this.log.error('[alerting] Alert channel delivery failed', { + alertName: event.name, + channel: ch.constructor.name, + error: err instanceof Error ? err.message : String(err), + }); + }), + ); + + await Promise.all(deliveries); + } +} + +// ─── Factory ────────────────────────────────────────────────────────────────── + +/** + * Create an alerting service. + * + * @param channels - One or more delivery channels (Slack, PagerDuty, …). + * @param log - Logger instance. Defaults to the shared structured logger. + * Must expose `error` and `warn` (see issue #1373). + */ +export function createAlertingService( + channels: AlertChannel[] = [], + log: Logger = defaultLogger, +): AlertingService { + return new AlertingServiceImpl(log, channels); +} + +/** Pre-built singleton backed by the default structured logger. */ +export const alertingService: AlertingService = createAlertingService(); diff --git a/backend/src/exposureGuardrails.ts b/backend/src/exposureGuardrails.ts new file mode 100644 index 00000000..9f449e10 --- /dev/null +++ b/backend/src/exposureGuardrails.ts @@ -0,0 +1,230 @@ +/** + * @file exposureGuardrails.ts + * Backend enforcement of protocol risk limits (Issue #1173). + * + * This module mirrors the hard-cap logic in `contracts/vault/src/risk_limits.rs` + * at the API layer so out-of-bounds deposits or invest calls are rejected before + * they reach the Soroban RPC — saving gas and giving callers a structured error + * rather than an opaque contract failure. + * + * Exposure is evaluated against the limits described in + * `docs/PROTOCOL_RISK_LIMITS.md`: + * - Max vault TVL (absolute cap, 0 = unlimited) + * - Max strategy concentration (bps of TVL) + * - Max deployed capital (bps of TVL) + * - Stress-mode tighter caps (StrConc / StrDep) + * + * Reads that do not mutate (all public helpers here) are deliberately left + * outside any transaction — see the note in `services/allocation.ts`. + * + * Issue #1373 — the Logger type injected into `createExposureGuardrails` must + * include `error` and `warn`. The `Logger` interface exported from + * `middleware/structuredLogging` satisfies that requirement. + */ + +import type { Logger } from './middleware/structuredLogging'; +import { logger as defaultLogger } from './middleware/structuredLogging'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Basis points (1 bps = 0.01%). 10_000 bps = 100%. */ +export type Bps = number; + +/** + * Snapshot of the current protocol risk configuration. + * Zero values indicate "unlimited" for numeric caps. + */ +export interface ProtocolRiskConfig { + /** Hard cap on total vault TVL in USDC-equivalent base units. 0 = unlimited. */ + maxVaultTvl: bigint; + /** Max concentration of any single strategy, in bps. */ + maxConcBps: Bps; + /** Max share of TVL that may be deployed (invested out of idle), in bps. */ + maxDeployedBps: Bps; + /** Stress-mode concentration cap (applies when stressMode is true). */ + stressConcBps: Bps; + /** Stress-mode deployed capital cap. */ + stressDeployedBps: Bps; + /** When true, the tighter stress caps are applied. */ + stressMode: boolean; +} + +export const DEFAULT_RISK_CONFIG: Readonly = Object.freeze({ + maxVaultTvl: 0n, // unlimited + maxConcBps: 10_000, // 100% + maxDeployedBps: 10_000, // 100% + stressConcBps: 5_000, // 50% + stressDeployedBps: 7_000, // 70% + stressMode: false, +}); + +/** Context supplied when checking a deposit. */ +export interface DepositCheckInput { + currentTvl: bigint; + depositAmount: bigint; +} + +/** Context supplied when checking a strategy invest / rebalance. */ +export interface InvestCheckInput { + totalTvl: bigint; + /** Amount already allocated to the target strategy before this invest. */ + currentStrategyAmount: bigint; + /** Additional amount to invest into the strategy. */ + investAmount: bigint; + /** Total capital currently deployed across all strategies. */ + totalDeployedAmount: bigint; +} + +export type GuardrailOutcome = 'ok' | 'rejected'; + +export interface GuardrailResult { + outcome: GuardrailOutcome; + reason?: string; +} + +/** Public surface of the exposure guardrails service. */ +export interface ExposureGuardrails { + /** Returns `ok` when the deposit fits within the TVL cap. */ + checkDeposit(input: DepositCheckInput): GuardrailResult; + /** Returns `ok` when the invest operation satisfies concentration + deployed caps. */ + checkInvest(input: InvestCheckInput): GuardrailResult; + /** True when stress-mode caps are currently active. */ + readonly isStressMode: boolean; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +function bpsOf(amount: bigint, total: bigint): Bps { + if (total === 0n) return 0; + // Convert to integer bps: (amount * 10_000) / total, rounded down. + return Number((amount * 10_000n) / total); +} + +class ExposureGuardrailsImpl implements ExposureGuardrails { + private readonly log: Logger; + private readonly cfg: ProtocolRiskConfig; + + constructor(log: Logger, cfg: ProtocolRiskConfig) { + this.log = log; + this.cfg = cfg; + } + + get isStressMode(): boolean { + return this.cfg.stressMode; + } + + checkDeposit(input: DepositCheckInput): GuardrailResult { + const { currentTvl, depositAmount } = input; + const { maxVaultTvl } = this.cfg; + + // maxVaultTvl === 0n means unlimited + if (maxVaultTvl === 0n) { + this.log.debug('[exposureGuardrails] TVL cap is unlimited, deposit allowed', { + currentTvl: currentTvl.toString(), + depositAmount: depositAmount.toString(), + }); + return { outcome: 'ok' }; + } + + const projected = currentTvl + depositAmount; + + if (projected > maxVaultTvl) { + const reason = + `Deposit of ${depositAmount} would push TVL to ${projected}, ` + + `exceeding the max cap of ${maxVaultTvl}`; + this.log.warn('[exposureGuardrails] Deposit rejected: TVL cap exceeded', { + currentTvl: currentTvl.toString(), + depositAmount: depositAmount.toString(), + projected: projected.toString(), + maxVaultTvl: maxVaultTvl.toString(), + }); + return { outcome: 'rejected', reason }; + } + + this.log.debug('[exposureGuardrails] Deposit allowed', { + currentTvl: currentTvl.toString(), + depositAmount: depositAmount.toString(), + projected: projected.toString(), + maxVaultTvl: maxVaultTvl.toString(), + }); + return { outcome: 'ok' }; + } + + checkInvest(input: InvestCheckInput): GuardrailResult { + const { totalTvl, currentStrategyAmount, investAmount, totalDeployedAmount } = input; + const { maxConcBps, maxDeployedBps, stressConcBps, stressDeployedBps, stressMode } = this.cfg; + + const effectiveConcBps = stressMode + ? Math.min(maxConcBps, stressConcBps) + : maxConcBps; + const effectiveDeployedBps = stressMode + ? Math.min(maxDeployedBps, stressDeployedBps) + : maxDeployedBps; + + if (totalTvl === 0n) { + this.log.warn('[exposureGuardrails] Invest check called with zero TVL; rejecting', { + investAmount: investAmount.toString(), + }); + return { outcome: 'rejected', reason: 'TVL is zero; cannot evaluate concentration' }; + } + + // Concentration check + const projectedStrategyAmount = currentStrategyAmount + investAmount; + const projectedConcBps = bpsOf(projectedStrategyAmount, totalTvl); + + if (projectedConcBps > effectiveConcBps) { + const reason = + `Strategy concentration would reach ${projectedConcBps} bps, ` + + `exceeding the ${stressMode ? 'stress-mode ' : ''}cap of ${effectiveConcBps} bps`; + this.log.warn('[exposureGuardrails] Invest rejected: concentration cap exceeded', { + projectedConcBps, + effectiveConcBps, + stressMode, + totalTvl: totalTvl.toString(), + investAmount: investAmount.toString(), + }); + return { outcome: 'rejected', reason }; + } + + // Deployed capital check + const projectedDeployedAmount = totalDeployedAmount + investAmount; + const projectedDeployedBps = bpsOf(projectedDeployedAmount, totalTvl); + + if (projectedDeployedBps > effectiveDeployedBps) { + const reason = + `Deployed capital would reach ${projectedDeployedBps} bps, ` + + `exceeding the ${stressMode ? 'stress-mode ' : ''}cap of ${effectiveDeployedBps} bps`; + this.log.warn('[exposureGuardrails] Invest rejected: deployed capital cap exceeded', { + projectedDeployedBps, + effectiveDeployedBps, + stressMode, + totalTvl: totalTvl.toString(), + investAmount: investAmount.toString(), + }); + return { outcome: 'rejected', reason }; + } + + this.log.debug('[exposureGuardrails] Invest allowed', { + projectedConcBps, + projectedDeployedBps, + stressMode, + }); + return { outcome: 'ok' }; + } +} + +// ─── Factory ────────────────────────────────────────────────────────────────── + +/** + * Create an exposure guardrails evaluator. + * + * @param cfg - Risk configuration. Defaults to `DEFAULT_RISK_CONFIG`. + * @param log - Logger instance. Defaults to the shared structured logger. + * Must expose `error` and `warn` (see issue #1373). + */ +export function createExposureGuardrails( + cfg: ProtocolRiskConfig = DEFAULT_RISK_CONFIG, + log: Logger = defaultLogger, +): ExposureGuardrails { + return new ExposureGuardrailsImpl(log, cfg); +} diff --git a/backend/src/middleware/structuredLogging.ts b/backend/src/middleware/structuredLogging.ts index 3ea109d2..f5e0219b 100644 --- a/backend/src/middleware/structuredLogging.ts +++ b/backend/src/middleware/structuredLogging.ts @@ -6,6 +6,20 @@ import { redactSensitiveLogAttributes } from '../auditRedaction'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +/** + * Structural interface for the Logger so modules that accept a logger via + * dependency injection can type their parameter without importing the private + * Logger class directly. All four log-level methods must be present. + * + * The concrete `logger` singleton exported below satisfies this interface. + */ +export interface Logger { + debug(message: string, fields?: Partial): void; + info(message: string, fields?: Partial): void; + warn(message: string, fields?: Partial): void; + error(message: string, fields?: Partial): void; +} + export interface LogEntry { timestamp: string; level: LogLevel; @@ -21,7 +35,7 @@ export interface LogEntry { [key: string]: unknown; } -class Logger { +class LoggerImpl implements Logger { private minLevel: LogLevel = 'info'; private readonly levelOrder: Record = { debug: 0, @@ -77,7 +91,7 @@ class Logger { } } -export const logger = new Logger(); +export const logger: Logger = new LoggerImpl(); export const structuredLoggingMiddleware: RequestHandler = ( req: CorrelationIdRequest,