diff --git a/.env.example b/.env.example index e360ae78..5a658af8 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,9 @@ SOROBAN_FEE_PERCENTILE=p50 INTENT_RETENTION_DAYS=30 INTENT_RETENTION_SWEEP_MS=60000 +# Time allowed to collect connected solver RFQ responses (1-1000 ms). +QUOTE_AUCTION_WINDOW_MS=300 + # ─── CORS ──────────────────────────────────────────────────────────────────── # Comma-separated list of allowed origins for the frontend. # Development default: "*" (any origin allowed — convenient for local work) diff --git a/.env.mainnet.example b/.env.mainnet.example index f3cb6fc9..f406137d 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -94,6 +94,7 @@ CORS_ORIGIN=https://app.vortex.trade # # ─── WebSocket ─────────────────────────────────────────────────────────────── # Tune based on expected solver + frontend connection count. WS_MAX_CONNECTIONS=5000 +QUOTE_AUCTION_WINDOW_MS=300 # ─── Pluggable signer backend (issue #400) ─────────────────────────────────── # REQUIRED in production: use SIGNER_BACKEND=vault so the signing key never diff --git a/.env.staging.example b/.env.staging.example index 60763ec8..8a73c9b9 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -39,6 +39,7 @@ SOROBAN_FEE_PERCENTILE=p50 CORS_ORIGIN=* WS_MAX_CONNECTIONS=1000 +QUOTE_AUCTION_WINDOW_MS=300 # ─── Pluggable signer backend (issue #400) ─────────────────────────────────── SIGNER_BACKEND=local diff --git a/.env.testnet.example b/.env.testnet.example index 1d170111..b054f4e0 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -20,6 +20,21 @@ NODE_ENV=development # ─── Stellar / Soroban ─────────────────────────────────────────────────────── STELLAR_NETWORK=testnet +INTENTS_PERSISTENCE=prisma +ETHEREUM_RPC_URL= +ETHEREUM_ESCROW_ADDRESS= +BASE_RPC_URL= +BASE_ESCROW_ADDRESS= +POLYGON_RPC_URL= +POLYGON_ESCROW_ADDRESS= +ARBITRUM_RPC_URL= +ARBITRUM_ESCROW_ADDRESS= +OPTIMISM_RPC_URL= +OPTIMISM_ESCROW_ADDRESS= +AVALANCHE_RPC_URL= +AVALANCHE_ESCROW_ADDRESS= +EVM_RPC_ALLOWLIST= +ALLOW_LEGACY_STELLAR_SIGNATURES=false SOROBAN_RPC_URL=https://soroban-testnet.stellar.org # Testnet contract IDs — leave blank until you have deployed contracts. @@ -53,6 +68,7 @@ CORS_ORIGIN=* # ─── WebSocket ─────────────────────────────────────────────────────────────── WS_MAX_CONNECTIONS=1000 +QUOTE_AUCTION_WINDOW_MS=300 # ─── Pluggable signer backend (issue #400) ─────────────────────────────────── # SIGNER_BACKEND=local is the default for development. @@ -120,3 +136,134 @@ PARAMS_POLL_INTERVAL_MS=30000 LEADER_ELECTION_ENABLED=false LEADER_ELECTION_HEARTBEAT_MS=5000 +# ─── Background jobs (issue #494) ──────────────────────────────────────────── +# api | worker | all — queue workers only run in "worker" or "all". +PROCESS_ROLE=all +# memory (single-process, dev/test) | bullmq (Redis-backed, uses REDIS_URL) +JOBS_DRIVER=memory +# Grace period for in-flight jobs on SIGTERM before they are returned to the queue. +JOBS_SHUTDOWN_TIMEOUT_MS=25000 + +# ─── Runtime feature flags (issue #495) ────────────────────────────────────── +# Change propagation across instances: memory (single instance) | redis +FLAGS_PUBSUB=memory +# Safety-net cache reload interval (ms) +FLAGS_REFRESH_MS=30000 +# Break-glass pins that win over DB state, e.g. onchain-dry-run=true +FLAG_OVERRIDES= + +# ─── Admin RBAC ────────────────────────────────────────────────────────────── +# Comma-separated id:role:secret (role = admin | superadmin, secret >= 16 chars). +# Sent as the x-admin-key header (the secret part). Empty disables admin APIs. +ADMIN_API_KEYS= + +# ─── Guardian emergency ingestion (issue #507) ─────────────────────────────── +# Guardian / security-council contract ID. Leave blank to disable ingestion. +GUARDIAN_CONTRACT_ID= + +# ─── Synthetic canary (issue #496) ─────────────────────────────────────────── +# Canary user + solver addresses; excluded from public stats and leaderboards. +CANARY_ADDRESSES= + +# Public anonymised datasets (docs/rfcs/0001) +# Master switch for the public dataset publication job. +DATASETS_ENABLED=false +# Hash user addresses with the rotating salt before export. +DATASETS_ANONYMIZE=true +# Base anonymisation salt. Required (>= 32 chars) when datasets are enabled +# and anonymisation is on; generate with `openssl rand -hex 32`. +DATASETS_SALT= +# How often the anonymisation salt rotates, in hours. +DATASETS_SALT_ROTATION_HOURS=24 +# How many previous salt windows are retained for continuity. +DATASETS_SALT_RETENTION_WINDOWS=2 +# Public bucket/prefix the published datasets live under. +DATASETS_PUBLIC_BUCKET=vortex-public-datasets +# Storage backend: local (writes to disk) | memory (tests only). +DATASETS_STORAGE=local +# Root directory for the local storage backend. +DATASETS_LOCAL_DIR=.datasets + +# Secrets Manager (issue #465) +# Provider: env | aws-secrets-manager | vault-kv +SECRETS_PROVIDER=env +# Poll interval for secret rotation (ms) +SECRETS_REFRESH_INTERVAL_MS=60000 +# Extra secrets: comma-separated "name:envVar:required" +SECRETS_EXTRA= + +# AWS Secrets Manager +AWS_SECRETS_MANAGER_PREFIX= +AWS_SECRETS_MANAGER_POLL_INTERVAL_MS=60000 + +# Vault KV +VAULT_KV_MOUNT=secret +VAULT_KV_PREFIX=vortex/ +VAULT_KV_POLL_INTERVAL_MS=60000 + +# Extra secret env vars referenced by the default SecretConfig +JWT_SIGNING_KEY= +WEBHOOK_SECRET= +CHANNEL_KEY= + +# Egress/SSRF Protection +EGRESS_TIMEOUT_MS=10000 +EGRESS_MAX_REDIRECTS=3 +EGRESS_MAX_BODY_SIZE_BYTES=10485760 +SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org +WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com +ORACLE_ALLOWLIST=oracle.trusted.io +# ─── WS gateway hardening (issue #455) ─────────────────────────────────────── +# Inbound frames larger than this close the socket (1009). +WS_MAX_PAYLOAD_BYTES=16384 +# Concurrent WS connections per client IP (0 = unlimited). +WS_MAX_CONNECTIONS_PER_IP=20 +# Trusted reverse-proxy hops for X-Forwarded-For (0 = socket address only). +WS_TRUST_PROXY_HOPS=0 +# Inbound token bucket per connection; repeat violators are disconnected. +WS_RATE_LIMIT_PER_SEC=10 +WS_RATE_LIMIT_BURST=20 +WS_RATE_LIMIT_MAX_VIOLATIONS=5 +# Outbound backpressure: messages held per slow consumer, socket buffer +# threshold (bytes), and what to do when the queue is full. +WS_OUTBOUND_QUEUE_MAX=1000 +WS_OUTBOUND_BUFFER_BYTES=1048576 +WS_SLOW_CONSUMER_POLICY=drop_oldest +# HS256 secret for solver JWTs from the SEP-10 auth flow (#442); >= 32 chars. +# Empty disables JWT auth on the WS gateway. +AUTH_JWT_SECRET= + +# ─── API keys & distributed rate limiting (issue #441) ───────────────────────── +RATE_LIMIT_LOCAL_PRUNE_MS=60000 +# Redis URL for the shared rate-limit window. Empty = bounded local limiter. +RATE_LIMIT_REDIS_URL= + +# ─── Scoped solver credentials (issue #443) ─────────────────────────────────── +CREDENTIAL_REVOCATION_PUBSUB=memory + +# ─── SSE intent feed (issue #433) ───────────────────────────────────────────── +SSE_HEARTBEAT_MS=15000 +SSE_MAX_BUFFER_BYTES=1048576 + +# ─── Public anonymised datasets ────────────────────────────────────────────── +DATASETS_ENABLED=false +DATASETS_ANONYMIZE=true +DATASETS_SALT= +DATASETS_SALT_ROTATION_HOURS=24 +DATASETS_SALT_RETENTION_WINDOWS=2 +DATASETS_PUBLIC_BUCKET= +DATASETS_STORAGE_KIND=memory +DATASETS_LOCAL_DIR= + +# ─── Health probes (issue #492) ────────────────────────────────────────────── +# Roles served by this process (api, ws, worker); readiness checks follow them. +SERVICE_ROLES=api,ws,worker +HEALTH_CHECK_INTERVAL_MS=5000 +# Readiness hysteresis: failures before not-ready, successes before ready again. +HEALTH_READY_FAILURE_THRESHOLD=3 +HEALTH_READY_SUCCESS_THRESHOLD=2 +# Liveness fails when event-loop delay exceeds this. +HEALTH_EVENT_LOOP_MAX_LAG_MS=1000 +# Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL). +SOROBAN_RPC_HEALTH_URLS= + diff --git a/docs/solver-onboarding.md b/docs/solver-onboarding.md index ad992d3a..f03bfe01 100644 --- a/docs/solver-onboarding.md +++ b/docs/solver-onboarding.md @@ -173,6 +173,38 @@ Upon subscription, the WebSocket server responds with a `subscribed` event: ``` Subsequent `intent_created` events will only be broadcast to the bot if the intent's `srcChain` matches one of the subscribed chains. +### RFQ Quote Requests +Authenticated, active solvers with a positive bond and matching source-chain/token capabilities may receive a short-lived `rfq_request` over this WebSocket. The default response window is 300 ms and can be configured from 1 to 1,000 ms with `QUOTE_AUCTION_WINDOW_MS`. + +```json +{ + "type": "rfq_request", + "requestId": "550e8400-e29b-41d4-a716-446655440000", + "srcChain": "ethereum", + "srcTokenSymbol": "USDC", + "srcAmount": "1000000", + "dstTokenSymbol": "USDC", + "deadline": 1775836800300 +} +``` + +Reply before `deadline` with the gross destination amount, solver fee, expiry in Unix seconds, and a Stellar Ed25519 signature: + +```json +{ + "type": "rfq_response", + "requestId": "550e8400-e29b-41d4-a716-446655440000", + "dstAmount": "998500", + "fee": "100", + "expiresAt": 1775836860, + "signature": "base64EncodedSignatureString==" +} +``` + +Sign the UTF-8 bytes of `vortex:rfq:v1::`. `payloadHash` is the lowercase SHA-256 hex digest of the JSON encoding of the request fields (`requestId`, `srcChain`, `srcTokenSymbol`, `srcAmount`, `dstTokenSymbol`, optional `srcTokenAddress` and `dstTokenContract`, and `deadline`) plus `solver`, `dstAmount`, `fee`, and `expiresAt`. Omit absent optional fields and sort keys lexicographically before `JSON.stringify`. The signature is Base64-encoded. The backend accepts one valid response per solver and request; late, expired, malformed, or invalidly signed responses are ignored. + +Quotes are ranked by destination amount after solver and protocol fees, with reputation breaking ties. If no valid solver response arrives within the window, the API returns the existing model-based estimate with `indicative: true`; otherwise `indicative` is false. + ### Event Replay & Reconnection On connection or reconnection, the bot can request event replay from its last received sequence ID (`seq`) to avoid missing intents during network blips: ```json diff --git a/scripts/db-migrate-locked.spec.ts b/scripts/db-migrate-locked.spec.ts index 4efc0145..14cb54ea 100644 --- a/scripts/db-migrate-locked.spec.ts +++ b/scripts/db-migrate-locked.spec.ts @@ -13,7 +13,7 @@ * `require` and typed here instead of via an ES import (no allowJs in * tsconfig, and the runtime must not depend on generated types). */ -// eslint-disable-next-line @typescript-eslint/no-require-imports +// eslint-disable-next-line @typescript-eslint/no-var-requires const migrate = require("./db-migrate-locked.js") as { CHECKPOINT_DDL: string; CHECKPOINT_TABLE: string; diff --git a/src/common/rfq-signature.ts b/src/common/rfq-signature.ts new file mode 100644 index 00000000..2cad43d7 --- /dev/null +++ b/src/common/rfq-signature.ts @@ -0,0 +1,15 @@ +import { createHash } from "node:crypto"; +import { RfqResponseSignaturePayload } from "../intents/rfq.types"; + +/** Canonical domain-separated message signed by a solver for an RFQ response. */ +export function buildRfqResponseMessage(payload: RfqResponseSignaturePayload): string { + const canonicalPayload = JSON.stringify( + Object.fromEntries( + Object.entries(payload) + .filter(([, value]) => value !== undefined) + .sort(([left], [right]) => left.localeCompare(right)), + ), + ); + const payloadHash = createHash("sha256").update(canonicalPayload, "utf8").digest("hex"); + return `vortex:rfq:v1:${payload.requestId}:${payloadHash}`; +} \ No newline at end of file diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index a598fcca..d84fbb36 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -11,6 +11,61 @@ */ import { Keypair } from "@stellar/stellar-sdk"; import { UnauthorizedException } from "@nestjs/common"; +import { createHash } from "node:crypto"; +import { Keypair } from "@stellar/stellar-sdk"; +import { UnauthorizedException } from "@nestjs/common"; + +export const INTENT_SIGNATURE_CLOCK_SKEW_SECONDS = 30; +export const MAX_INTENT_SIGNATURE_TTL_SECONDS = 900; + +export interface IntentSignatureContext { + network: string; + nonce: string; + expiresAt: number; +} + +function canonicalPayload(payload: Record): string { + return JSON.stringify( + Object.fromEntries(Object.entries(payload).sort(([left], [right]) => left.localeCompare(right))), + ); +} + +function buildV2IntentMessage( + context: IntentSignatureContext, + action: "accept" | "fill" | "cancel", + intentId: string, + payload: Record, +): string { + const payloadHash = createHash("sha256").update(canonicalPayload(payload), "utf8").digest("hex"); + return `vortex:${context.network}:${action}:${intentId}:${context.nonce}:${context.expiresAt}:${payloadHash}`; +} + +/** + * Verify that `signature` (base64) over `message` (utf-8) was produced by + * the private key corresponding to `publicKey` (Stellar G-address). + * + * Throws UnauthorizedException on any failure so callers can let it propagate + * straight to the HTTP layer. + */ +export function verifyStellarSignature( + publicKey: string, + message: string, + signature: string, +): void { + try { + const keypair = Keypair.fromPublicKey(publicKey); + const messageBytes = Buffer.from(message, "utf8"); + const signatureBytes = Buffer.from(signature, "base64"); + if (!keypair.verify(messageBytes, signatureBytes)) { + throw new UnauthorizedException("Invalid Stellar signature"); + } + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException("Invalid Stellar signature"); + } +} /** * Verify that `signature` (base64) over `message` (utf-8) was produced by @@ -42,7 +97,11 @@ export function verifyStellarSignature( /** * Build the canonical message that a user must sign to cancel an intent. */ -export function buildCancelMessage(intentId: string): string { +export function buildCancelMessage(intentId: string, context?: IntentSignatureContext, user?: string): string { + if (context) return buildV2IntentMessage(context, "cancel", intentId, { user: user ?? "" }); + + return `cancel:${intentId}`; +} return `cancel:${intentId}`; } @@ -56,14 +115,32 @@ export function buildWsAuthMessage(solver: string, timestamp: number | string): /** * Build the canonical message that a solver must sign to accept an intent. */ -export function buildAcceptMessage(intentId: string, solver: string): string { +export function buildAcceptMessage(intentId: string, solver: string, context?: IntentSignatureContext): string { + if (context) return buildV2IntentMessage(context, "accept", intentId, { solver }); + + return `accept:${intentId}:${solver}`; +} return `accept:${intentId}:${solver}`; } /** * Build the canonical message that a solver must sign to fill an intent. */ -export function buildFillMessage(intentId: string, solver: string): string { +export function buildFillMessage( + intentId: string, + solver: string, + context?: IntentSignatureContext, + fill?: { fillAmount: string; txHash?: string }, +): string { + if (context) { + return buildV2IntentMessage(context, "fill", intentId, { + solver, + fillAmount: fill?.fillAmount ?? "", + txHash: fill?.txHash ?? null, + }); + } + return `fill:${intentId}:${solver}`; +} return `fill:${intentId}:${solver}`; } @@ -114,3 +191,16 @@ export function buildDisputeReviewMessage(disputeId: string): string { export function buildDisputeDecisionMessage(disputeId: string, resolution: string, reason: string): string { return `dispute-decision:${disputeId}:${resolution}:${reason}`; } + +/** + * Build the canonical message that a solver must sign to update their mutable + * profile fields (name / supportedChains / supportedTokens / avgFillTime). + * + * Signing over just the address is sufficient here: it proves control of the + * account whose profile is being edited, and the request body is already + * constrained by the DTO whitelist so no immutable field can ride along. + */ +export function buildUpdateSolverMessage(address: string): string { + return `update-solver:${address}`; +} + diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 7a03b151..4cdfd256 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -118,8 +118,17 @@ export interface AppConfig { address: string; }; onchainIntentsEnabled: boolean; + legacyStellarSignatures: boolean; + evm: { + rpcAllowlist: string[]; + chains: Record< + "ethereum" | "base" | "polygon" | "arbitrum" | "optimism" | "avalanche", + { chainId: number; rpcUrl: string; escrowAddress: string } + >; + }; intentRetentionDays: number; intentRetentionSweepMs: number; + quoteAuctionWindowMs: number; /** * Dry-run flag for on-chain write paths (issue #260). * @@ -172,6 +181,34 @@ export interface AppConfig { */ pollMs: number; }; +lane is never open. + */ + operatorToken: string; + /** + * Redis URL used for cross-replica pause propagation. Empty falls back to + * database polling only, which still meets the propagation budget. + */ + redisUrl: string; + /** + * Interval (ms) for the `max_updated_at` probe that backstops Redis pub/sub. + * Worst-case propagation delay is roughly this value, so it must stay + * comfortably under the 5 s propagation requirement. + */ + pollMs: number; + }; + + /** + * Shadow-mode divergence monitor (issue #401). + * + * Runs read-only on-chain simulations of every intent state transition in + * parallel with the authoritative off-chain path and reports where the two + * disagree. See docs/runbooks/onchain-cutover.md for the go/no-go threshold. + */ + shadow: { + /** Master switch. When false, `ShadowService.observe` is a no-op. */ + enabled: boolean; + /** Fraction of transitions to simulate, in `[0, 1]`. `1` = every one. */ + sampleR /** * Shadow-mode divergence monitor (issue #401). * @@ -197,6 +234,36 @@ export interface AppConfig { */ sourceAccount: string; }; + + governance: { + /** + * On-chain governance / parameters contract ID. + * When set, ProtocolParamsService reads current + scheduled parameters + * from this contract and exposes them via GET /api/v1/params. + * Leave blank to use code / env defaults only. + */ + paramsContractId: string; + /** + * How often (in milliseconds) to poll the parameters contract for changes. + * Default: 30 000 ms (30 s). + */ + paramsPollIntervalMs: number; + }; + + governance: { + /** + * On-chain governance / parameters contract ID. + * When set, ProtocolParamsService reads current + scheduled parameters + * from this contract and exposes them via GET /api/v1/params. + * Leave blank to use code / env defaults only. + */ + paramsContractId: string; + /** + * How often (in milliseconds) to poll the parameters contract for changes. + * Default: 30 000 ms (30 s). + */ + paramsPollIntervalMs: number; + }; governance: { /** * On-chain governance / parameters contract ID. @@ -222,6 +289,20 @@ export interface AppConfig { * queue workers only run when the role is "worker" or "all". */ processRole: "api" | "worker" | "all"; + jobs: { + /** "memory" (single-process, dev/test) or "bullmq" (Redis-backed, durable). */ + drive + leaderElection: { + /** When false, all workers run unconditionally (pre-election behaviour). */ + enabled: boolean; + /** Heartbeat interval in ms (default 5000). */ + heartbeatMs: number; + }; + /** + * Process role (issue #494). Producers may enqueue jobs from any role; + * queue workers only run when the role is "worker" or "all". + */ + processRole: "api" | "worker" | "all"; jobs: { /** "memory" (single-process, dev/test) or "bullmq" (Redis-backed, durable). */ driver: "memory" | "bullmq"; @@ -253,6 +334,75 @@ export interface AppConfig { storageKind: "local" | "memory"; localDir: string; }; + secrets: { + /** Provider name: "env" | "aws-secrets-manager" | "vault-kv". */ + provider: "env" | "aws-secrets-manager" | "vault-kv"; + /** Poll interval for secret rotation (ms). */ + refreshIntervalMs: number; + /** Comma-separated extra secrets: "name:envVar:required". */ + extra: string; + }; + /** WS gateway hardening (issue #455). */ + ws: { + /** Largest inbound frame accepted; larger frames close the socket (1009). */ + maxPayloadBytes: number; + /** Concurrent connections allowed from one client IP (0 = unlimited). */ + maxConnectionsPerIp: number; + /** Reverse-proxy hops to trust when reading X-Forwarded-For (0 = use the socket address). */ + trustProxyHops: number; + /** Inbound token bucket: sustained messages per second and burst size. */ + rateLimitPerSec: number; + rateLimitBurst: number; + /** Rate-limited messages tolerated before the connection is closed (1008). */ + rateLimitMaxViolations: number; + /** Messages held for a slow consumer before the slow-consumer policy applies. */ + outboundQueueMax: number; + /** Socket bufferedAmount above which further messages are queued instead of sent. */ + outboundBufferBytes: number; + slowConsumerPolicy: "drop_oldest" | "disconnect"; + /** Drain timeout for graceful shutdown (Activity 2). */ + drainTimeoutMs: number; + }; + /** HS256 secret for solver JWTs (SEP-10 auth, #442); empty disables JWT auth. */ + authJwtSecret: string; + /** + * How often (ms) the local rate-limiter fallback prunes expired window + * entries (issue #441). Only relevant during a Redis outage. + */ + rateLimitLocalPruneMs: number; + /** + * Redis URL backing the distributed rate limiter (issue #441). Empty means + * "local bounded limiter only" — the limit is still enforced, just per + * process. Defaults to `REDIS_URL` when that is set. + */ + rateLimitRedisUrl: string; + /** + * Cross-replica transport for solver-credential revocation invalidation + * (issue #443): "memory" (single instance) or "redis" (pub/sub). + */ + credentialRevocationPubsub: "memory" | "redis"; + /** SSE intent feed (issue #433). */ + sse: { + /** Heartbeat interval in milliseconds (SSE comment frames). */ + heartbeatMs: number; + /** Maximum buffered output bytes per SSE client before it is disconnected. */ + maxBufferBytes: number; + }; + /** Health probes (issue #492). */ + health: { + /** Roles this process serves; readiness requires every indicator critical to any of them. */ + roles: Array<"api" | "ws" | "worker">; + /** Background re-check interval; probes only read cached results. */ + checkIntervalMs: number; + /** Consecutive failed evaluations before readiness turns false. */ + readyFailureThreshold: number; + /** Consecutive passing evaluations before readiness turns true again. */ + readySuccessThreshold: number; + /** Event-loop delay above which liveness fails. */ + eventLoopMaxLagMs: number; + /** Soroban RPC endpoints probed for quorum (majority must be healthy). */ + rpcHealthUrls: string[]; + }; } export default (): AppConfig => ({ @@ -275,8 +425,22 @@ export default (): AppConfig => ({ address: process.env.TREASURY_ADDRESS ?? "", }, onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", + legacyStellarSignatures: + process.env.ALLOW_LEGACY_STELLAR_SIGNATURES === "true" || process.env.NODE_ENV === "test", + evm: { + rpcAllowlist: (process.env.EVM_RPC_ALLOWLIST ?? "").split(",").map((host) => host.trim()).filter(Boolean), + chains: { + ethereum: { chainId: 1, rpcUrl: process.env.ETHEREUM_RPC_URL ?? "", escrowAddress: process.env.ETHEREUM_ESCROW_ADDRESS ?? "" }, + base: { chainId: 8453, rpcUrl: process.env.BASE_RPC_URL ?? "", escrowAddress: process.env.BASE_ESCROW_ADDRESS ?? "" }, + polygon: { chainId: 137, rpcUrl: process.env.POLYGON_RPC_URL ?? "", escrowAddress: process.env.POLYGON_ESCROW_ADDRESS ?? "" }, + arbitrum: { chainId: 42161, rpcUrl: process.env.ARBITRUM_RPC_URL ?? "", escrowAddress: process.env.ARBITRUM_ESCROW_ADDRESS ?? "" }, + optimism: { chainId: 10, rpcUrl: process.env.OPTIMISM_RPC_URL ?? "", escrowAddress: process.env.OPTIMISM_ESCROW_ADDRESS ?? "" }, + avalanche: { chainId: 43114, rpcUrl: process.env.AVALANCHE_RPC_URL ?? "", escrowAddress: process.env.AVALANCHE_ESCROW_ADDRESS ?? "" }, + }, + }, intentRetentionDays: parseInt(process.env.INTENT_RETENTION_DAYS ?? "30", 10), intentRetentionSweepMs: parseInt(process.env.INTENT_RETENTION_SWEEP_MS ?? "60000", 10), + quoteAuctionWindowMs: parseInt(process.env.QUOTE_AUCTION_WINDOW_MS ?? "300", 10), // Default to dry-run (true) outside production; in production the value must // be explicitly set (validated by envValidationSchema). onchainDryRun: process.env.ONCHAIN_DRY_RUN !== undefined @@ -351,6 +515,48 @@ export default (): AppConfig => ({ storageKind: (process.env.DATASETS_STORAGE ?? "local") as "local" | "memory", localDir: process.env.DATASETS_LOCAL_DIR ?? ".datasets", }, + secrets: { + provider: (process.env.SECRETS_PROVIDER ?? "env") as "env" | "aws-secrets-manager" | "vault-kv", + refreshIntervalMs: parseInt(process.env.SECRETS_REFRESH_INTERVAL_MS ?? "60000", 10), + extra: process.env.SECRETS_EXTRA ?? "", + }, + ws: { + maxPayloadBytes: parseInt(process.env.WS_MAX_PAYLOAD_BYTES ?? "16384", 10), + maxConnectionsPerIp: parseInt(process.env.WS_MAX_CONNECTIONS_PER_IP ?? "20", 10), + trustProxyHops: parseInt(process.env.WS_TRUST_PROXY_HOPS ?? "0", 10), + rateLimitPerSec: Number(process.env.WS_RATE_LIMIT_PER_SEC ?? "10"), + rateLimitBurst: parseInt(process.env.WS_RATE_LIMIT_BURST ?? "20", 10), + rateLimitMaxViolations: parseInt(process.env.WS_RATE_LIMIT_MAX_VIOLATIONS ?? "5", 10), + outboundQueueMax: parseInt(process.env.WS_OUTBOUND_QUEUE_MAX ?? "1000", 10), + outboundBufferBytes: parseInt(process.env.WS_OUTBOUND_BUFFER_BYTES ?? "1048576", 10), + slowConsumerPolicy: (process.env.WS_SLOW_CONSUMER_POLICY ?? "drop_oldest") as AppConfig["ws"]["slowConsumerPolicy"], + drainTimeoutMs: parseInt(process.env.WS_DRAIN_TIMEOUT_MS ?? "25000", 10), + }, + authJwtSecret: process.env.AUTH_JWT_SECRET ?? "", + rateLimitLocalPruneMs: parseInt(process.env.RATE_LIMIT_LOCAL_PRUNE_MS ?? "60000", 10), + // Redis URL for the distributed rate limiter. Defaults to REDIS_URL so an + // existing multi-replica deployment keeps a global quota; set it explicitly + // to "" to force the bounded local limiter (single-replica / test). + rateLimitRedisUrl: process.env.RATE_LIMIT_REDIS_URL ?? process.env.REDIS_URL ?? "", + credentialRevocationPubsub: (process.env.CREDENTIAL_REVOCATION_PUBSUB ?? "memory") as "memory" | "redis", + sse: { + heartbeatMs: parseInt(process.env.SSE_HEARTBEAT_MS ?? "15000", 10), + maxBufferBytes: parseInt(process.env.SSE_MAX_BUFFER_BYTES ?? "1048576", 10), + }, + health: { + roles: (process.env.SERVICE_ROLES ?? "api,ws,worker") + .split(",") + .map((r) => r.trim()) + .filter(Boolean) as AppConfig["health"]["roles"], + checkIntervalMs: parseInt(process.env.HEALTH_CHECK_INTERVAL_MS ?? "5000", 10), + readyFailureThreshold: parseInt(process.env.HEALTH_READY_FAILURE_THRESHOLD ?? "3", 10), + readySuccessThreshold: parseInt(process.env.HEALTH_READY_SUCCESS_THRESHOLD ?? "2", 10), + eventLoopMaxLagMs: parseInt(process.env.HEALTH_EVENT_LOOP_MAX_LAG_MS ?? "1000", 10), + rpcHealthUrls: (process.env.SOROBAN_RPC_HEALTH_URLS || process.env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org") + .split(",") + .map((u) => u.trim()) + .filter(Boolean), + }, }); /** Parse `SHADOW_SAMPLE_RATE` into a probability, defaulting to full sampling. */ diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index b007055e..e69de29b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,373 +0,0 @@ -import * as Joi from "joi"; - -// Stellar secret seeds ("S..." strkeys) are 56-char base32: prefix + 32-byte -// payload + checksum. This rejects placeholders like "changeme" outright — -// it does not by itself prove the key is a *real, funded* signer. -const STELLAR_SECRET_KEY_PATTERN = /^S[A-Z2-7]{55}$/; - -// One message for both "absent" and "empty". Joi's .required() alone accepts an -// empty string, which for the kill-switch would be a silently disabled control -// plane — the exact condition this rule exists to prevent, so both cases must -// produce the same actionable error. -const KILLSWITCH_TOKEN_REQUIRED_MESSAGE = - "KILLSWITCH_OPERATOR_TOKEN must be a non-empty secret in production so the " + - "emergency pause control plane (/api/v1/ops/killswitch) is usable. Generate " + - "one with `openssl rand -hex 32`. See docs/runbooks/killswitch.md."; - -export const envValidationSchema = Joi.object({ - NODE_ENV: Joi.string().valid("development", "production", "test").default("development"), - PORT: Joi.number().port().default(4000), - - // Prisma requires DATABASE_URL in production; optional (with a default) in - // development/test so the app can boot without a live database for unit tests. - DATABASE_URL: Joi.string() - .uri({ scheme: ["postgresql", "postgres"] }) - .default("postgresql://vortex:vortex@localhost:5432/vortex?schema=public"), - - STELLAR_NETWORK: Joi.string().valid("testnet", "futurenet", "mainnet").default("testnet"), - SOROBAN_RPC_URL: Joi.string().uri().default("https://soroban-testnet.stellar.org"), - // Horizon base URL, used for account/balance reads (treasury, canary tooling). - HORIZON_URL: Joi.string().uri().default("https://horizon-testnet.stellar.org"), - SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), - SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), - STELLAR_SIGNER_SECRET_KEY: Joi.string().allow("").default(""), - - // Secret key for the backend's own Soroban signer (submits on-chain writes - // such as settlement and slashing calls). No default is provided anywhere - // in this schema — an unset value fails closed (empty string) rather than - // ever falling back to a placeholder that could be mistaken for a real key. - SOROBAN_SIGNING_KEY: Joi.string() - .pattern(STELLAR_SECRET_KEY_PATTERN) - .messages({ - "string.pattern.base": - 'SOROBAN_SIGNING_KEY must be a valid Stellar secret seed (starts with "S", 56 base32 characters). ' + - "Generate a throwaway testnet key for local dev — see README's Signing Key section — never commit a real one.", - }) - .when("NODE_ENV", { - is: "production", - then: Joi.required(), - otherwise: Joi.string().allow("").default(""), - }), - - ONCHAIN_INTENTS_ENABLED: Joi.boolean().default(false), - // Stellar public key of the treasury account (fee/slash/refund accumulator). - TREASURY_ADDRESS: Joi.string().allow("").default(""), - CORS_ORIGIN: Joi.string().default("*"), - WS_MAX_CONNECTIONS: Joi.number().integer().min(0).default(1000), - SOROBAN_FEE_PERCENTILE: Joi.string() - .valid( - "min", - "mode", - "p10", - "p20", - "p30", - "p40", - "p50", - "p60", - "p70", - "p80", - "p90", - "p95", - "p99", - "max", - ) - .default("p50"), - - WS_BACKPLANE: Joi.string().valid("memory", "redis").default("memory"), - REDIS_URL: Joi.string().uri({ scheme: ["redis", "rediss"] }).default("redis://localhost:6379"), - - // ── Persistence adapter selection ───────────────────────────────────────── - // Controls which repository adapter is used for intents and solvers. - // "memory" (default) keeps everything in-process — no database required. - // "prisma" writes to PostgreSQL via Prisma — requires DATABASE_URL to point - // to a live database. Intended for production / staging. - INTENTS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - SOLVERS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - - // ── Intent retention (in-memory store hygiene) ───────────────────────────── - // How long terminal intents are kept in the in-memory adapter, and how often - // the eviction sweep runs. Both are read by IntentsService. - INTENT_RETENTION_DAYS: Joi.number().integer().min(0).default(30), - INTENT_RETENTION_SWEEP_MS: Joi.number().integer().min(0).default(60000), - - // ── Reference solver bot (scripts/solver-bot.ts) ─────────────────────────── - // Read by the standalone bot process rather than by the server, but declared - // here so `npm run check:env-drift` sees one consistent variable set across - // env.validation.ts, configuration.ts and the .env*.example files. - SOLVER_SECRET: Joi.string().allow("").default(""), - SOLVER_ADDRESS: Joi.string().allow("").default(""), - SOLVER_CHAINS: Joi.string().allow("").default(""), - - // ── Observability ───────────────────────────────────────────────────────── - // Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry. - SENTRY_DSN: Joi.string().uri().allow("").default(""), - - // Winston log level. Defaults to "debug" in dev/test and "info" in production. - LOG_LEVEL: Joi.string() - .valid("error", "warn", "info", "http", "verbose", "debug", "silly") - .default( - // Joi.ref doesn't evaluate lazily here, so we rely on the logger's own - // resolveLogLevel() for the runtime default — this schema default acts - // as a documentation hint and config validation guard only. - "debug", - ), - - // Log shipping — off by default so local dev/CI remain stdout-only. When - // enabled, structured logs are also shipped to LOG_SHIPPING_HOST:PORT. - LOG_SHIPPING_ENABLED: Joi.boolean().default(false), - LOG_SHIPPING_HOST: Joi.string().when("LOG_SHIPPING_ENABLED", { - is: true, - then: Joi.required(), - otherwise: Joi.string().allow("").default(""), - }), - LOG_SHIPPING_PORT: Joi.number().port().when("LOG_SHIPPING_ENABLED", { - is: true, - then: Joi.required(), - otherwise: Joi.number().optional(), - }), - LOG_SHIPPING_PATH: Joi.string().default("/"), - LOG_SHIPPING_SSL: Joi.boolean().default(false), - LOG_SERVICE_NAME: Joi.string().default("vortex-backend"), - - // ── Pluggable signer backend (issue #400) ──────────────────────────────── - // SIGNER_BACKEND selects which signing implementation is used: - // "local" (default) — LocalKeypairSigner: key loaded from SOROBAN_SIGNING_KEY / file. - // Refused in production unless ALLOW_LOCAL_SIGNER_IN_PROD=true. - // "vault" — VaultTransitSigner: signs via HashiCorp Vault Transit (ed25519). - // Requires VAULT_ADDR + VAULT_TOKEN. Key never enters RAM. - SIGNER_BACKEND: Joi.string().valid("local", "vault").default("local"), - - // Required when SIGNER_BACKEND=vault. - VAULT_ADDR: Joi.string().uri({ scheme: ["http", "https"] }).when("SIGNER_BACKEND", { - is: "vault", - then: Joi.required(), - otherwise: Joi.string().allow("").default(""), - }), - VAULT_TOKEN: Joi.string().when("SIGNER_BACKEND", { - is: "vault", - then: Joi.required(), - otherwise: Joi.string().allow("").default(""), - }), - // Name of the Vault Transit key (default: "vortex-signer"). - VAULT_TRANSIT_KEY_NAME: Joi.string().default("vortex-signer"), - - // Escape hatch: allow LocalKeypairSigner in production. - // Must be explicitly set to "true" — any other value is treated as false. - // A startup warning is emitted when this is enabled in production. - ALLOW_LOCAL_SIGNER_IN_PROD: Joi.boolean().default(false), - // ── Resource-exhaustion limits (issue #476) ─────────────────────────────── - // These values are consumed by src/config/limits.config.ts at startup and - // override the compile-time defaults when set. All have safe defaults so - // the service can boot without them. - - /** Max JSON nesting depth before the body is rejected (default 10). */ - JSON_MAX_DEPTH: Joi.number().integer().min(1).max(100).default(10), - - /** Max WS chain-filter values per subscribe message (default 20). */ - WS_MAX_FILTER_CHAINS: Joi.number().integer().min(1).max(100).default(20), - - /** Max active subscriptions per WS connection (default 10). */ - WS_MAX_SUBSCRIPTIONS: Joi.number().integer().min(1).max(100).default(10), - - /** Default Postgres statement_timeout in ms for standard route queries (default 5000). */ - DB_QUERY_TIMEOUT_MS: Joi.number().integer().min(100).max(60000).default(5000), - - /** Postgres statement_timeout in ms for batch-lookup queries (default 10000). */ - DB_BATCH_QUERY_TIMEOUT_MS: Joi.number().integer().min(100).max(60000).default(10000), - - /** Postgres statement_timeout in ms for stats/aggregate queries (default 15000). */ - DB_STATS_QUERY_TIMEOUT_MS: Joi.number().integer().min(100).max(60000).default(15000), - - // ── Emergency kill-switch (issue #477) ───────────────────────────────────── - // Shared secret for the operator control plane. Empty (the default) leaves - // /api/v1/ops/killswitch disabled — fail closed, never open. - // - // The kill-switch is the only way to stop writes at runtime, so a production - // deploy without a token ships a protocol that cannot be paused. Requiring it - // in production fails validation rather than silently running with the - // control plane disabled. - KILLSWITCH_OPERATOR_TOKEN: Joi.string() - .when("NODE_ENV", { - is: Joi.valid("production"), - then: Joi.string() - .required() - .invalid("") - .messages({ - "any.required": KILLSWITCH_TOKEN_REQUIRED_MESSAGE, - "string.empty": KILLSWITCH_TOKEN_REQUIRED_MESSAGE, - "any.invalid": KILLSWITCH_TOKEN_REQUIRED_MESSAGE, - }), - otherwise: Joi.string().allow("").default(""), - }), - - /** - * Redis URL for cross-replica pause propagation. Empty means "polling only", - * which still meets the 5 s budget. Defaults to reusing REDIS_URL when - * WS_BACKPLANE=redis, so existing deployments propagate without new config. - */ - KILLSWITCH_REDIS_URL: Joi.string().allow("").optional(), - - /** - * DB change-probe interval (ms) that backstops Redis pub/sub. Capped at 5000 - * so the worst-case propagation delay cannot exceed the requirement, however - * misconfigured. - */ - KILLSWITCH_POLL_MS: Joi.number().integer().min(100).max(5000).default(2000), - - // Same adapter-selection convention as the other repositories. - KILLSWITCH_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - - // ── On-chain write safety flag (issue #35 / issue #260) ────────────────── - // When true, every on-chain-write code path (invokeContract, slashSolver) - // builds and simulates the transaction, logs what it *would* submit, and - // returns without broadcasting — safe by construction. - // - // Default behaviour: - // - Outside production: defaults to true (simulate-only, fail closed - // toward safety — no real funds moved without an explicit opt-out). - // - In production: *required* to be explicitly set. Omitting it in a - // production deploy fails validation so the operator must consciously - // decide between dry-run and live mode before traffic reaches - // on-chain write paths. This matches the fail-closed pattern used - // for SOROBAN_SIGNING_KEY. - // - // This is the env default for the `onchain-dry-run` runtime feature flag - // (issue #495); the flag can override it without a restart, and turning - // dry-run off in production through the flag requires two approvals. - // Set ONCHAIN_DRY_RUN=false only after completing the dry-run soak - // described in docs/runbooks/onchain-cutover.md. - ONCHAIN_DRY_RUN: Joi.boolean() - .when("NODE_ENV", { - is: "production", - then: Joi.required().messages({ - "any.required": - "ONCHAIN_DRY_RUN must be explicitly set in production. " + - "Set to true to remain in simulate-only mode, or false to enable live on-chain writes. " + - "See docs/runbooks/onchain-cutover.md for the staged rollout procedure.", - }), - otherwise: Joi.boolean().default(true), - }), - - // ── Shadow-mode divergence monitor (issue #401) ─────────────────────────── - // Runs read-only on-chain simulations of every intent state transition in - // parallel with the authoritative off-chain path and reports where the two - // disagree. Never submits a transaction; see src/soroban/shadow.service.ts. - // - // Off by default: a sampled simulation is a real RPC call with a real - // rate-limit footprint, so it is an explicit per-environment opt-in. - SHADOW_MODE_ENABLED: Joi.boolean().default(false), - - // Fraction of transitions to simulate, as a probability in [0, 1]. - // 1 (the default) compares every transition; 0 disables sampling entirely - // while leaving the monitor "enabled" — useful for a canary that only wants - // the queue/metric plumbing live. - SHADOW_SAMPLE_RATE: Joi.number().min(0).max(1).default(1), - - // Hard cap on queued observations. Beyond this, observations are dropped and - // counted (`vortex_shadow_dropped_total`) rather than queued, so a slow or - // unreachable RPC degrades the monitor instead of the service. - SHADOW_QUEUE_MAX: Joi.number().integer().min(1).default(256), - - // How many queued observations the background drain simulates concurrently. - SHADOW_CONCURRENCY: Joi.number().integer().min(1).max(32).default(4), - - // Public key used as the transaction source for shadow simulations. A Stellar - // public key (strkey G...). It is never signed, never submitted and never - // charged a fee — it only has to be a valid address for the envelope. - // Optional: when empty the monitor reports `contract_unconfigured` rather - // than silently recording zero divergence. - SHADOW_SOURCE_ACCOUNT: Joi.string().allow("").default(""), - // ── Governance parameters contract ──────────────────────────────────────── - // When set, ProtocolParamsService reads current + scheduled protocol - // parameters (fee bps, fill windows, deadlines, exposure ratio, slash - // amount) from this Soroban contract address. Leave blank to use code - // and env defaults. - PARAMS_CONTRACT_ID: Joi.string().allow("").default(""), - - // How often (ms) to poll the parameters contract. 30 s is the default; - // lower values increase RPC load; raise in production if rate-limited. - PARAMS_POLL_INTERVAL_MS: Joi.number().integer().min(5_000).default(30_000), - // ── Leader election (issue #493) ────────────────────────────────────────── - // Controls whether Postgres advisory-lock based leader election is enabled - // for singleton workers (sweeper, event-ingestion). - // - // Set LEADER_ELECTION_ENABLED=false in single-instance dev deployments or - // when no database is available. When disabled, every worker considers - // itself leader unconditionally — the pre-election behaviour. - // - // IMPORTANT: Do NOT route the leader election connection through PgBouncer - // in transaction-pooling mode. Advisory locks are session-scoped; they are - // released when the connection is returned to the pool. Use a direct - // connection or PgBouncer in session mode. - LEADER_ELECTION_ENABLED: Joi.boolean().default(false), - - // Heartbeat interval in milliseconds — how often non-leaders attempt to - // acquire the lock and leaders renew it. Lower values reduce failover time - // but increase DB load. Default 5 s gives ≤ 15 s failover. - LEADER_ELECTION_HEARTBEAT_MS: Joi.number().integer().min(1000).max(60000).default(5000), - - // ── Background jobs (issue #494) ────────────────────────────────────────── - // PROCESS_ROLE: "api" serves HTTP/WS only, "worker" runs queue workers, - // "all" does both (single-process dev default). Producers work in any role. - PROCESS_ROLE: Joi.string().valid("api", "worker", "all").default("all"), - // JOBS_DRIVER: "memory" is single-process and non-durable (dev/test); - // "bullmq" uses REDIS_URL and is required for multi-instance deploys. - JOBS_DRIVER: Joi.string().valid("memory", "bullmq").default("memory"), - JOBS_SHUTDOWN_TIMEOUT_MS: Joi.number().integer().min(0).default(25000), - - // ── Runtime feature flags (issue #495) ──────────────────────────────────── - FLAGS_PUBSUB: Joi.string().valid("memory", "redis").default("memory"), - FLAGS_REFRESH_MS: Joi.number().integer().min(1000).default(30000), - // Comma-separated "key=true|false" pins that win over DB state (break-glass). - FLAG_OVERRIDES: Joi.string() - .allow("") - .pattern(/^([a-z0-9-]+=(true|false))(,[a-z0-9-]+=(true|false))*$/) - .default(""), - - // ── Admin RBAC ──────────────────────────────────────────────────────────── - // Comma-separated "id:role:secret" entries; role is "admin" or "superadmin". - // Empty disables every admin endpoint (401). - ADMIN_API_KEYS: Joi.string() - .allow("") - .pattern(/^([A-Za-z0-9_.-]+:(admin|superadmin):[^,:]{16,})(,[A-Za-z0-9_.-]+:(admin|superadmin):[^,:]{16,})*$/) - .default(""), - - // ── Guardian emergency ingestion (issue #507) ───────────────────────────── - GUARDIAN_CONTRACT_ID: Joi.string().allow("").default(""), - - // ── Synthetic canary (issue #496) ───────────────────────────────────────── - // Comma-separated canary user/solver addresses, excluded from public stats - // and leaderboards. - CANARY_ADDRESSES: Joi.string().allow("").default(""), - - // ── Public anonymised datasets (docs/rfcs/0001) ─────────────────────────── - DATASETS_ENABLED: Joi.boolean().default(false), - DATASETS_ANONYMIZE: Joi.boolean().default(true), - // Required only when datasets are enabled AND anonymisation is on — an - // empty/weak salt would collapse pseudonymisation to a fixed, reversible - // transform. It stays optional (default "") otherwise so existing dev/test - // configs are unaffected. - DATASETS_SALT: Joi.string() - .when("DATASETS_ENABLED", { - is: true, - then: Joi.string().when("DATASETS_ANONYMIZE", { - is: true, - then: Joi.string() - .min(32) - .required() - .messages({ - "any.required": - "DATASETS_SALT must be set when DATASETS_ENABLED=true and DATASETS_ANONYMIZE=true. " + - "Generate a strong random secret (e.g. `openssl rand -hex 32`).", - "string.min": "DATASETS_SALT must be at least 32 characters.", - }), - otherwise: Joi.string().allow("").default(""), - }), - otherwise: Joi.string().allow("").default(""), - }), - DATASETS_SALT_ROTATION_HOURS: Joi.number().integer().min(1).default(24), - DATASETS_SALT_RETENTION_WINDOWS: Joi.number().integer().min(0).default(2), - DATASETS_PUBLIC_BUCKET: Joi.string().default("vortex-public-datasets"), - DATASETS_STORAGE: Joi.string().valid("local", "memory").default("local"), - DATASETS_LOCAL_DIR: Joi.string().default(".datasets"), -}); diff --git a/src/intents/dto/quote-response.dto.ts b/src/intents/dto/quote-response.dto.ts index ee9ddcf7..cbbe944f 100644 --- a/src/intents/dto/quote-response.dto.ts +++ b/src/intents/dto/quote-response.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { TokenInfo } from "../intents.types"; export class RouteStepDto { @@ -100,4 +100,7 @@ export class QuoteResponseDto { @ApiProperty({ description: "Price impact for the best quote as a decimal fraction (0 when no quote available)" }) priceImpact!: number; + + @ApiPropertyOptional({ description: "True when no solver responded and the returned quote is indicative" }) + indicative?: boolean; } diff --git a/src/intents/feed/intent-feed.service.ts b/src/intents/feed/intent-feed.service.ts index aa08f00c..2b5c1a52 100644 --- a/src/intents/feed/intent-feed.service.ts +++ b/src/intents/feed/intent-feed.service.ts @@ -13,6 +13,17 @@ import { MemoryBackplane } from "../backplane/memory.backplane"; import { EventRingBuffer } from "../event-ring-buffer"; import { FeedClient, FeedAdmission, FeedFilter, FeedReplayResult } from "./feed.types"; import { resolveClientIp } from "../ws/connection-state"; +import { verifyStellarSignature } from "../../common/stellar-signature"; +import { buildRfqResponseMessage } from "../../common/rfq-signature"; +import { RfqQuoteRequest, RfqResponseSignaturePayload, VerifiedRfqQuote } from "../rfq.types"; + +interface PendingRfq { + request: RfqQuoteRequest; + eligibleSolvers: Set; + responses: Map; + resolve: (responses: VerifiedRfqQuote[]) => void; + timer: ReturnType; +} /** * How many sequenced events to keep in the replay buffer. @@ -46,6 +57,7 @@ export class IntentFeedService implements OnModuleDestroy { /** Connected clients and their per-connection filters. */ private readonly clients = new Map(); + private readonly pendingRfqs = new Map(); /** Per-IP connection accounting (shared by WS and SSE). */ private readonly connectionsPerIp = new Map(); @@ -150,6 +162,119 @@ export class IntentFeedService implements OnModuleDestroy { return this.backplane.publish(event); } + requestRfq( + request: Omit, + eligibleSolvers: string[], + windowMs: number, + ): Promise { + const eligible = new Set(eligibleSolvers); + if (eligible.size === 0) return Promise.resolve([]); + + const rfq: RfqQuoteRequest = { + ...request, + requestId: randomUUID(), + deadline: Date.now() + windowMs, + }; + + return new Promise((resolve) => { + const timer = setTimeout(() => this.completeRfq(rfq.requestId), windowMs); + this.pendingRfqs.set(rfq.requestId, { + request: rfq, + eligibleSolvers: eligible, + responses: new Map(), + resolve, + timer, + }); + + void this.backplane + .publish({ type: "rfq_request", ...rfq, eligibleSolvers: [...eligible] }) + .catch(() => this.completeRfq(rfq.requestId)); + }); + } + + submitRfqResponse(response: { + solver: string; + requestId: string; + dstAmount: string; + fee: string; + expiresAt: number; + signature: string; + }): Promise { + return this.backplane.publish({ type: "rfq_response", ...response }); + } + + private completeRfq(requestId: string): void { + const pending = this.pendingRfqs.get(requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingRfqs.delete(requestId); + pending.resolve([...pending.responses.values()]); + } + + private deliverRfqRequest(event: SequencedEvent): void { + const eligibleSolvers = (event as SequencedEvent & { eligibleSolvers?: unknown }).eligibleSolvers; + if (!Array.isArray(eligibleSolvers)) return; + const eligible = new Set(eligibleSolvers.filter((solver): solver is string => typeof solver === "string")); + const { seq, eligibleSolvers: _eligibleSolvers, ...request } = event as SequencedEvent & { + eligibleSolvers: string[]; + }; + const payload = JSON.stringify({ seq, ...request }); + + for (const [client, filter] of this.clients) { + if (filter.solver && eligible.has(filter.solver.solverAddress)) { + this.sendToClient(client, payload, seq); + } + } + } + + private acceptRfqResponse(event: SequencedEvent): void { + const response = event as SequencedEvent & { + solver?: unknown; + requestId?: unknown; + dstAmount?: unknown; + fee?: unknown; + expiresAt?: unknown; + signature?: unknown; + }; + const { solver, requestId, dstAmount, fee, expiresAt, signature } = response; + if ( + typeof solver !== "string" || + typeof requestId !== "string" || + typeof dstAmount !== "string" || + typeof fee !== "string" || + typeof expiresAt !== "number" || + typeof signature !== "string" + ) return; + + const pending = this.pendingRfqs.get(requestId); + if ( + !pending || + Date.now() >= pending.request.deadline || + !pending.eligibleSolvers.has(solver) || + pending.responses.has(solver) + ) return; + if (!/^\d{1,78}$/.test(dstAmount) || !/^\d{1,78}$/.test(fee) || !Number.isSafeInteger(expiresAt)) return; + + const now = Math.floor(Date.now() / 1000); + if (expiresAt <= now || expiresAt > now + 60) return; + + try { + if (BigInt(dstAmount) <= BigInt(fee)) return; + const payload: RfqResponseSignaturePayload = { + ...pending.request, + solver, + dstAmount, + fee, + expiresAt, + }; + verifyStellarSignature(solver, buildRfqResponseMessage(payload), signature); + } catch { + return; + } + + pending.responses.set(solver, { solver, dstAmount, fee, expiresAt }); + } + /** Chains deliveries so async chain lookups cannot reorder events. */ private enqueueDelivery(event: SequencedEvent): Promise { const run = this.deliveryChain.then(() => this.deliver(event)); @@ -167,6 +292,15 @@ export class IntentFeedService implements OnModuleDestroy { const enqueuedAt = Date.now(); const { seq, ...event } = sequencedEvent; + if (event.type === "rfq_request") { + this.deliverRfqRequest(sequencedEvent); + return; + } + if (event.type === "rfq_response") { + this.acceptRfqResponse(sequencedEvent); + return; + } + this.updateIndexForEvent(sequencedEvent); this.ringBuffer.push(sequencedEvent); @@ -283,7 +417,7 @@ export class IntentFeedService implements OnModuleDestroy { /** Current sequence number (0 when no events have been broadcast). */ get currentSeq(): number { - return this.ringBuffer.latestSeq(); + return this.backplane.health().lastSeq; } // ── Solver capability ─────────────────────────────────────────────────── @@ -367,6 +501,7 @@ export class IntentFeedService implements OnModuleDestroy { } async onModuleDestroy(): Promise { + for (const requestId of this.pendingRfqs.keys()) this.completeRfq(requestId); await this.backplane.close(); for (const [client] of this.clients) { this.removeClient(client); diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 7962f140..e69de29b 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -1,812 +0,0 @@ -import { - BadRequestException, - Body, - ConflictException, - Controller, - ForbiddenException, - Get, - GoneException, - NotFoundException, - Param, - Post, - Query, - UnprocessableEntityException, - UseGuards, -} from "@nestjs/common"; -import { - ApiTags, - ApiOkResponse, - ApiNotFoundResponse, - ApiConflictResponse, - ApiForbiddenResponse, - ApiGoneResponse, - ApiBadRequestResponse, - ApiTooManyRequestsResponse, - ApiOperation, - ApiServiceUnavailableResponse, - ApiUnprocessableEntityResponse, -} from "@nestjs/swagger"; -import { Throttle } from "@nestjs/throttler"; -import { IntentsService } from "./intents.service"; -import { IntentsGateway } from "./intents.gateway"; -import { SolversService } from "../solvers/solvers.service"; -import { TokensService } from "../tokens/tokens.service"; -import { RoutingService } from "../routing/routing.service"; -import { MAX_OPEN_INTENTS_PER_USER, NewIntentData } from "./intents.service"; -import { CreateIntentDto } from "./dto/create-intent.dto"; -import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; -import { AcceptIntentDto } from "./dto/accept-intent.dto"; -import { FillIntentDto } from "./dto/fill-intent.dto"; -import { CancelIntentDto } from "./dto/cancel-intent.dto"; -import { QuoteRequestDto } from "./dto/quote-request.dto"; -import { QuoteResponseDto } from "./dto/quote-response.dto"; -import { ListIntentsDto } from "./dto/list-intents.dto"; -import { BatchLookupDto } from "./dto/batch-lookup.dto"; -import { - BatchCreateIntentsDto, - BatchCreateIntentsResponseDto, -} from "./dto/batch-create-intents.dto"; -import { BATCH_CREATE_MAX_INTENTS } from "../config/limits.config"; -import { UserThrottlerGuard } from "./user-throttler.guard"; -import { - verifyStellarSignature, - buildAcceptMessage, - buildCancelMessage, - buildFillMessage, -} from "../common/stellar-signature"; -import { - applyVarianceScale, - calculateProtocolFee, - parseBaseUnits, - toDecimalNumber, - varianceScaleFromPerfScore, -} from "../common/amount"; -import { Intent, SupportedChain } from "./intents.types"; -import { - assertNotPaused, - KillSwitchGate, - KillSwitchGuard, -} from "../killswitch/killswitch.guard"; -import { KillSwitchService } from "../killswitch/killswitch.service"; -import { KillSwitchOperation } from "../killswitch/killswitch.types"; -import { ConfigService } from "@nestjs/config"; -import { AppConfig } from "../config/configuration"; -import { isCanaryIntent } from "../common/canary"; - -@ApiTags("intents") -@Controller({ path: "intents", version: "1" }) -export class IntentsController { - constructor( - private readonly intentsService: IntentsService, - private readonly solversService: SolversService, - private readonly intentsGateway: IntentsGateway, - private readonly tokensService: TokensService, - private readonly routingService: RoutingService, - private readonly killSwitch: KillSwitchService, - config: ConfigService, - ) { - this.canary = new Set(config.get("canaryAddresses", { infer: true }) ?? []); - } - - /** Canary addresses (issue #496). */ - private readonly canary: ReadonlySet; - - /** - * Re-assert the kill-switch hierarchy against a *loaded* intent. - * - * `KillSwitchGuard` runs before the handler and can only read the route path - * and body. For `:id` routes that is not enough to evaluate a chain- or - * token-scoped pause, so `accept` and `fill` call this once the record is in - * hand. The global-scope and snapshot-readiness checks are still done by the - * guard, so this is strictly additional coverage, not a replacement. - */ - private assertIntentNotPaused(intent: Intent, operation: KillSwitchOperation): void { - assertNotPaused( - this.killSwitch, - { - chain: intent.srcChain, - // Prefer the contract address: symbols are not unique within a chain, - // so a symbol-scoped pause would over-match and an address-scoped one - // would under-match. Operators pause by address. - token: intent.srcToken?.address ?? null, - operation, - }, - { retryAfterSeconds: 30 }, - ); - } - - @Get() - @ApiBadRequestResponse({ description: "Invalid limit or offset" }) - async list(@Query() dto: ListIntentsDto) { - let intents = await this.intentsService.getAll(); - - if (dto.state) intents = intents.filter((i) => i.state === dto.state); - if (dto.user) intents = intents.filter((i) => i.user.toLowerCase() === dto.user!.toLowerCase()); - if (dto.chain) intents = intents.filter((i) => i.srcChain === dto.chain); - - const limit = Math.min(dto.limit ?? 20, 100); - const offset = dto.offset ?? 0; - - if ((dto.limit ?? 20) > 100) { - throw new BadRequestException("Limit exceeds maximum allowed value of 100"); - } - - const page = intents.slice(offset, offset + limit); - return { intents: page, total: intents.length, limit, offset }; - } - - @Get("open") - async listOpen(@Query() dto: ListIntentsDto) { - const open = await this.intentsService.getByState("open"); - const limit = Math.min(dto.limit ?? 20, 100); - const offset = dto.offset ?? 0; - - if ((dto.limit ?? 20) > 100) { - throw new BadRequestException("Limit exceeds maximum allowed value of 100"); - } - - const page = open.slice(offset, offset + limit); - return { intents: page, total: open.length, count: open.length, limit, offset }; - } - - @Get("user/:address") - async listByUser(@Param("address") address: string, @Query() dto: ListIntentsDto) { - const intents = await this.intentsService.getByUser(address); - const limit = Math.min(dto.limit ?? 20, 100); - const offset = dto.offset ?? 0; - - if ((dto.limit ?? 20) > 100) { - throw new BadRequestException("Limit exceeds maximum allowed value of 100"); - } - - const page = intents.slice(offset, offset + limit); - return { intents: page, total: intents.length, count: intents.length, limit, offset }; - } - - @Get(":id") - @ApiNotFoundResponse({ description: "Intent not found" }) - async getOne(@Param("id") id: string) { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - return intent; - } - - /** - * GET /api/v1/intents/:id/audit - * - * Returns the full state-transition history for an intent, oldest-first. - * Issue #217 — backs the in-memory audit trail with a persistent DB table - * (intent_audit_log) so the log survives restarts and is independently - * queryable (see DATABASE_INDEXES.md section 3 and the runbooks that depend - * on this trail: docs/runbooks/onchain-cutover.md, RUNBOOK_BACKUP_RESTORE.md). - */ - @Get(":id/audit") - @ApiOperation({ - summary: "Get audit trail for an intent", - description: - "Returns the full state-transition history for an intent ordered oldest-first. " + - "Each entry records the state the intent moved into, who triggered it, and why.", - }) - @ApiOkResponse({ - description: "Audit trail for the intent", - schema: { - type: "object", - properties: { - intentId: { type: "string" }, - entries: { - type: "array", - items: { - type: "object", - properties: { - timestamp: { type: "string", format: "date-time" }, - toState: { type: "string" }, - actor: { type: "string" }, - reason: { type: "string" }, - metadata: { type: "object", nullable: true }, - }, - }, - }, - }, - }, - }) - @ApiNotFoundResponse({ description: "Intent not found" }) - async getAudit(@Param("id") id: string, @Query() dto: ListIntentsDto) { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - - const limit = Math.min(dto.limit ?? 20, 100); - const offset = dto.offset ?? 0; - if ((dto.limit ?? 20) > 100) { - throw new BadRequestException("Limit exceeds maximum allowed value of 100"); - } - - const allEntries = this.intentsService.getAuditLog(id); - const entries = this.intentsService.getAuditLog(id, limit, offset); - const total = allEntries.length; - return { intentId: id, entries, total, limit, offset }; - } - - /** - * GET /api/v1/intents/:id/quote - * - * Returns the persisted best quote for an intent (the quotedDstAmount stored - * on the intent after a POST /quote call with intentId). - */ - @Get(":id/quote") - @ApiOkResponse({ description: "Persisted quote for the intent" }) - @ApiNotFoundResponse({ description: "Intent not found or no quote persisted" }) - async getPersistedQuote(@Param("id") id: string) { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - if (!intent.quotedDstAmount) throw new NotFoundException("No quote persisted for this intent"); - return { intentId: id, quotedDstAmount: intent.quotedDstAmount }; - } - - /** - * Issue #44 — global IP throttle already applied via AppModule guard. - * Issue #45 — additionally throttle per dto.user: 10 creates / 60 s. - */ - @Post() - @UseGuards(UserThrottlerGuard, KillSwitchGuard) - @KillSwitchGate({ operation: "create" }) - @ApiTooManyRequestsResponse({ - description: - "Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)", - }) - @ApiBadRequestResponse({ description: "Invalid request body" }) - @ApiConflictResponse({ - description: `Open-intent cap reached — a single user may not hold more than ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents simultaneously`, - }) - async create(@Body() dto: CreateIntentDto) { - const now = Math.floor(Date.now() / 1000); - - // #219: use typed resolveToken instead of ad-hoc duck-typed any casts. - // #276: reject unrecognised tokens outright instead of silently creating an - // intent whose priceUSD defaults to undefined. - // #473: enforce the per-user open-intent cap as a fast-path rejection. - // The atomic guarantee lives in the persistence layer (conditional write); - // this pre-check keeps the common over-cap case cheap without adding a - // round trip on the happy path. - const openCount = await this.intentsService.countOpenByUser(dto.user); - if (openCount >= MAX_OPEN_INTENTS_PER_USER) { - throw new ConflictException( - `Open-intent cap reached — max ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents per user`, - ); - } - const srcToken = await this.tokensService.resolveSrcTokenOrThrow( - dto.srcChain as SupportedChain, - dto.srcTokenAddress, - ); - const dstToken = await this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract); - - const intent = await this.intentsService.create( - { - user: dto.user, - srcChain: dto.srcChain, - srcToken: { - address: dto.srcTokenAddress, - symbol: dto.srcTokenSymbol, - name: dto.srcTokenSymbol, - decimals: dto.srcTokenDecimals, - chain: dto.srcChain, - priceUSD: srcToken?.priceUSD, - }, - srcAmount: dto.srcAmount, - dstToken: { - contract: dto.dstTokenContract, - symbol: dto.dstTokenSymbol, - decimals: dto.dstTokenDecimals, - priceUSD: dstToken?.priceUSD, - }, - minDstAmount: dto.minDstAmount, - deadline: dto.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[dto.srcChain] ?? DEFAULT_DEADLINE_SECONDS), - }, - dto.idempotencyKey, - ); - this.intentsGateway.broadcast({ type: "intent_created", intent }); - return intent; - } - - /** - * POST /api/v1/intents/batch - * - * Issue #275 — bounded batch status lookup. Lets a solver bot (or a frontend - * showing a full history) reconcile a known set of intent IDs against current - * server state in one call instead of N `GET /:id` requests. - * - * `POST` (not `GET`) because the ID list can exceed a comfortable query-string - * length. Subject to the same global rate limits as every other endpoint — - * no dedicated tier. Read-only: batch accept/fill/cancel is explicitly out of - * scope. - */ - @Post("batch") - @ApiOperation({ - summary: "Batch-fetch current intent records by ID", - description: - "Returns the current record for each supplied intent ID. IDs with no " + - "matching record are omitted (not individually 404'd). Capped at 100 IDs.", - }) - @ApiOkResponse({ description: "Records for the found intent IDs, plus a count" }) - @ApiBadRequestResponse({ - description: "intentIds missing, not an array of strings, or exceeds 100 entries", - }) - async batchLookup(@Body() dto: BatchLookupDto) { - const intents = await this.intentsService.getMany(dto.intentIds); - return { intents, count: intents.length }; - } - - /** - * POST /api/v1/intents/batch-create - * - * Issue #429 — Atomic batch intent creation endpoint. - * Creates up to N intents atomically (all-or-nothing) with per-item validation errors. - */ - @Post("batch-create") - @UseGuards(UserThrottlerGuard, KillSwitchGuard) - @KillSwitchGate({ operation: "create" }) - @ApiOperation({ - summary: "Create multiple intents atomically", - description: - "Creates up to 50 intents in a single atomic (all-or-nothing) request. " + - "If any item fails validation or exceeds open intent limits, zero intents are created " + - "and per-item errors are reported.", - }) - @ApiOkResponse({ type: BatchCreateIntentsResponseDto }) - @ApiBadRequestResponse({ description: "Invalid request payload or empty batch" }) - @ApiUnprocessableEntityResponse({ description: "Per-item validation errors or limits exceeded" }) - async batchCreate(@Body() dto: BatchCreateIntentsDto) { - if (!dto.intents || !Array.isArray(dto.intents) || dto.intents.length === 0) { - throw new BadRequestException("Intents array must contain at least 1 item"); - } - - if (dto.intents.length > BATCH_CREATE_MAX_INTENTS) { - throw new BadRequestException( - `Batch size exceeds maximum allowed limit of ${BATCH_CREATE_MAX_INTENTS}`, - ); - } - - const now = Math.floor(Date.now() / 1000); - const items: NewIntentData[] = []; - - for (let i = 0; i < dto.intents.length; i++) { - const itemDto = dto.intents[i]; - const srcToken = await this.tokensService.resolveSrcTokenOrThrow( - itemDto.srcChain as SupportedChain, - itemDto.srcTokenAddress, - ); - const dstToken = await this.tokensService.resolveDstTokenOrThrow(itemDto.dstTokenContract); - - items.push({ - user: itemDto.user, - srcChain: itemDto.srcChain, - srcToken: { - address: itemDto.srcTokenAddress, - symbol: itemDto.srcTokenSymbol, - name: itemDto.srcTokenSymbol, - decimals: itemDto.srcTokenDecimals, - chain: itemDto.srcChain, - priceUSD: srcToken?.priceUSD, - }, - srcAmount: itemDto.srcAmount, - dstToken: { - contract: itemDto.dstTokenContract, - symbol: itemDto.dstTokenSymbol, - decimals: itemDto.dstTokenDecimals, - priceUSD: dstToken?.priceUSD, - }, - minDstAmount: itemDto.minDstAmount, - deadline: itemDto.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[itemDto.srcChain] ?? DEFAULT_DEADLINE_SECONDS), - }); - } - - const result = await this.intentsService.createBatch(items); - - if (result.errors.length > 0) { - throw new UnprocessableEntityException({ - statusCode: 422, - message: "Batch intent creation failed validation", - created: [], - errors: result.errors, - }); - } - - for (const intent of result.created) { - this.intentsGateway.broadcast({ type: "intent_created", intent }); - } - - return { created: result.created, errors: [] }; - } - - @Post(":id/accept") - @UseGuards(KillSwitchGuard) - @KillSwitchGate({ operation: "accept" }) - @ApiNotFoundResponse({ description: "Intent not found" }) - @ApiConflictResponse({ description: "Intent is not in open state" }) - @ApiGoneResponse({ description: "Intent has expired" }) - @ApiForbiddenResponse({ description: "Solver not registered or inactive" }) - async accept(@Param("id") id: string, @Body() dto: AcceptIntentDto) { - // Fast-path snapshot only — guards below are advisory. The atomic - // decision is the conditional `acceptIfOpen` write (state=open AND - // deadline > now in SQL), so a concurrent cancel/expiry always wins. - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - - // The guard above can only see the path parameter, so it could not know - // which chain/token this intent belongs to. Re-assert now that the record - // is loaded, otherwise a chain- or token-scoped pause would not stop - // accepts. Deliberately placed after the 404 so an unknown id still 404s. - this.assertIntentNotPaused(intent, "accept"); - - const now = Math.floor(Date.now() / 1000); - if (intent.deadline <= now) { - // Atomic expiry attempt: never blindly overwrite — an `accepted` - // intent must slash, never expire (issue #473). - await this.intentsService.expireIfOpen(id); - throw new GoneException("Intent has expired"); - } - - // Verify the solver controls the claimed address before it can accept. - verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); - - const solver = await this.solversService.get(dto.solver); - if (!solver?.isActive) { - throw new ForbiddenException("Solver not registered or inactive"); - } - if (!solver.bondAmount || BigInt(solver.bondAmount) <= 0n) { - throw new ForbiddenException("Solver has insufficient bond"); - } - if (this.solversService.isSuspended(dto.solver)) { - throw new ForbiddenException("Solver is suspended by an active guardian action"); - } - // Canary intents pair only with canary solvers (issue #496) so synthetic - // traffic never affects real solvers' stats or real users' fills. - if (isCanaryIntent(intent, this.canary) !== this.canary.has(dto.solver)) { - throw new ForbiddenException("Canary intents may only be accepted by canary solvers, and vice versa"); - } - - const updated = await this.intentsService.acceptIfOpen(id, dto.solver, now); - if (!updated) { - const current = await this.intentsService.get(id); - if (!current) throw new NotFoundException("Intent not found"); - if ((current.deadline ?? 0) <= Math.floor(Date.now() / 1000)) { - throw new GoneException("Intent has expired"); - } - throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot accept`); - } - - this.intentsService.appendAuditEntry(id, "accepted", dto.solver, "solver accepted", { - deadline: updated.deadline, - }); - this.intentsGateway.broadcast({ - type: "intent_accepted", - intentId: id, - solver: dto.solver, - }); - return updated; - } - - @Post(":id/fill") - @UseGuards(KillSwitchGuard) - @KillSwitchGate({ operation: "fill" }) - @ApiNotFoundResponse({ description: "Intent not found" }) - @ApiServiceUnavailableResponse({ - description: "An emergency kill-switch is active for this intent's scope (503 + Retry-After)", - }) - @ApiConflictResponse({ description: "Intent is not in accepted state" }) - @ApiForbiddenResponse({ description: "Wrong solver for this intent" }) - @ApiGoneResponse({ description: "Fill window has expired" }) - @ApiBadRequestResponse({ description: "Fill amount below minimum" }) - async fill(@Param("id") id: string, @Body() dto: FillIntentDto) { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - - // Same reason as in `accept`: the route guard cannot resolve the intent's - // chain/token from `:id`, so re-assert against the loaded record. - this.assertIntentNotPaused(intent, "fill"); - - const now = Math.floor(Date.now() / 1000); - if (intent.deadline <= now) { - throw new GoneException("Fill window has expired"); - } - - // Verify the solver controls the claimed address - verifyStellarSignature(dto.solver, buildFillMessage(id, dto.solver), dto.signature); - - const fillAmount = parseBaseUnits(dto.fillAmount); - let minAmount: bigint; - try { - minAmount = BigInt(intent.minDstAmount); - } catch { - throw new BadRequestException({ - error: "Data integrity error: intent minDstAmount is not a valid integer", - intentId: id, - minDstAmount: intent.minDstAmount, - }); - } - if (fillAmount < minAmount) { - throw new BadRequestException({ - error: "Fill amount below minimum", - fillAmount: dto.fillAmount, - minDstAmount: intent.minDstAmount, - }); - } - - const feeAmount = (BigInt(dto.fillAmount) * 5n) / 10000n; - - const updated = await this.intentsService.fillIfAccepted(id, dto.solver, { - filledAt: now, - fillAmount: dto.fillAmount, - feeAmount: feeAmount.toString(), - txHash: dto.txHash, - }); - if (!updated) { - const current = await this.intentsService.get(id); - if (current?.solver !== dto.solver) { - throw new ForbiddenException("Wrong solver for this intent"); - } - throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot fill`); - } - - await this.solversService.recordSuccessfulFill(dto.solver); - - this.intentsService.appendAuditEntry(id, "filled", dto.solver, "solver filled", { - fillAmount: dto.fillAmount, - txHash: dto.txHash, - }); - this.intentsGateway.broadcast({ - type: "intent_filled", - intentId: id, - solver: dto.solver, - fillAmount: dto.fillAmount, - }); - return updated; - } - - @Post(":id/cancel") - @ApiNotFoundResponse({ description: "Intent not found" }) - @ApiForbiddenResponse({ description: "Unauthorized" }) - @ApiConflictResponse({ description: "Intent is not in open state" }) - async cancel(@Param("id") id: string, @Body() dto: CancelIntentDto) { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - if (intent.user.toLowerCase() !== dto.user.toLowerCase()) { - throw new ForbiddenException("Unauthorized"); - } - if (intent.state !== "open") { - throw new ConflictException(`Cannot cancel intent in state: ${intent.state}`); - } - - // Verify the user controls the claimed address - verifyStellarSignature(dto.user, buildCancelMessage(id), dto.signature); - - const updated = await this.intentsService.cancelIfOpen(id); - if (!updated) { - const current = await this.intentsService.get(id); - throw new ConflictException(`Cannot cancel intent in state: ${current?.state ?? "unknown"}`); - } - - // Audit trail (issue #217 / #62): record who cancelled and when. - this.intentsService.appendAuditEntry(id, "cancelled", dto.user, "user cancelled"); - - this.intentsGateway.broadcast({ type: "intent_cancelled", intentId: id }); - return updated; - } - - /** - * Issue #44 — document 429 on quote too, since it's under the global guard. - * Issue #220 — routes are now computed via RoutingService and attached to each quote. - */ - @Post("quote") - @Throttle({ default: { limit: 20, ttl: 60_000 } }) - @ApiTooManyRequestsResponse({ - description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", - }) - @ApiOkResponse({ type: QuoteResponseDto }) - async quote(@Body() dto: QuoteRequestDto): Promise { - const solvers = (await this.solversService.getAll()).filter((s) => s.isActive); - - // #219: use typed resolveSrcToken / resolveDstToken — no more any casts. - // #276: a quote may be requested by symbol alone (no contract/address), but - // when a token identifier IS supplied it must resolve — otherwise the quote - // engine would silently substitute a fake $1 price. - const srcToken = dto.srcTokenAddress - ? await this.tokensService.resolveSrcTokenOrThrow( - dto.srcChain as SupportedChain, - dto.srcTokenAddress, - ) - : undefined; - const dstToken = dto.dstTokenContract - ? await this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract) - : undefined; - - const srcAmountBigInt = parseBaseUnits(dto.srcAmount); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dstPriceUSD: number = (dstToken as any)?.priceUSD ?? 1; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const srcPriceUSD: number = (srcToken as any)?.priceUSD ?? dstPriceUSD; - - const quotes = solvers - .map((solver) => { - // Issue #118: weight variance by solver performance history. - const totalFills = solver.fillsCompleted + solver.fillsFailed; - const successRate = totalFills > 0 ? solver.fillsCompleted / totalFills : 0.5; - const fillCountScore = Math.min(solver.fillsCompleted / 100, 1); - const perfScore = successRate * 0.7 + fillCountScore * 0.3; - const varianceScaled = varianceScaleFromPerfScore(perfScore); - const dstAmount = applyVarianceScale(srcAmountBigInt, varianceScaled); - const fee = calculateProtocolFee(dstAmount); // 0.05% - - // Issue #126: compute USD fee total and price impact. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const feeUnits = toDecimalNumber(fee, (dstToken as any)?.decimals ?? 7); - const totalFeesUSD = feeUnits * dstPriceUSD; - const srcUnits = toDecimalNumber(srcAmountBigInt, srcToken?.decimals ?? 7); - const dstUnits = toDecimalNumber(dstAmount, dstToken?.decimals ?? 7); - const priceImpact = - srcPriceUSD > 0 && dstPriceUSD > 0 - ? Math.max(0, 1 - (dstUnits * dstPriceUSD) / (srcUnits * srcPriceUSD)) - : 0; - - // #220: attach a computed route to each solver quote. - // Build minimal TokenInfo objects for routing (uses resolved data when available). - const srcTokenInfo = { - address: dto.srcTokenAddress ?? "", - symbol: dto.srcTokenSymbol, - name: srcToken?.name ?? dto.srcTokenSymbol, - decimals: srcToken?.decimals ?? 18, - chain: (dto.srcChain as SupportedChain) ?? "ethereum", - priceUSD: srcToken?.priceUSD, - }; - const dstTokenInfo = { - address: dstToken?.contract ?? dto.dstTokenContract ?? "", - symbol: dto.dstTokenSymbol, - name: dstToken?.name ?? dto.dstTokenSymbol, - decimals: dstToken?.decimals ?? 7, - chain: "stellar" as SupportedChain, - priceUSD: dstToken?.priceUSD, - }; - - // Try a direct route; fall back to a two-hop via USDC intermediate when - // a direct solver path is not viable (different base tokens). - const route = this.routingService.buildRoute(srcTokenInfo, dstTokenInfo, solver.address, { - totalFeesUSD, - priceImpact, - estimatedFillTime: solver.avgFillTime + Math.floor(Math.random() * 30), - }); - - return { - solver: solver.address, - solverName: solver.name, - dstAmount: dstAmount.toString(), - fee: fee.toString(), - fillTime: solver.avgFillTime + Math.floor(Math.random() * 30), - expiresAt: Math.floor(Date.now() / 1000) + 60, - totalFeesUSD, - priceImpact, - route, - }; - }) - // nosemgrep: no-number-money -- sort comparator on bounded quote diffs only; amounts stay strings elsewhere. - .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); - - if (dto.intentId && quotes.length > 0) { - await this.intentsService.update(dto.intentId, { quotedDstAmount: quotes[0].dstAmount }); - } - - const best = quotes[0] ?? null; - return { - quotes, - bestQuote: best, - srcChain: dto.srcChain, - srcTokenSymbol: dto.srcTokenSymbol, - srcAmount: dto.srcAmount, - dstTokenSymbol: dto.dstTokenSymbol, - estimatedFillTime: best?.fillTime ?? 0, - totalFeesUSD: best?.totalFeesUSD ?? 0, - priceImpact: best?.priceImpact ?? 0, - }; - } - - /** - * POST /api/v1/intents/:id/requote - * - * Convenience endpoint for re-quoting an already-created intent without - * resupplying srcChain/srcToken/srcAmount/dstToken — they're read straight - * off the stored Intent record. Only valid while the intent is "open". - */ - @Post(":id/requote") - @Throttle({ default: { limit: 20, ttl: 60_000 } }) - @ApiOperation({ summary: "Re-quote an existing open intent using its stored fields" }) - @ApiTooManyRequestsResponse({ - description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", - }) - @ApiOkResponse({ type: QuoteResponseDto }) - @ApiNotFoundResponse({ description: "Intent not found" }) - @ApiConflictResponse({ description: "Intent is not in the open state" }) - async requote(@Param("id") id: string): Promise { - const intent = await this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - if (intent.state !== "open") { - throw new ConflictException( - `Cannot requote intent in state "${intent.state}"; only open intents can be requoted`, - ); - } - - const solvers = (await this.solversService.getAll()).filter((s) => s.isActive); - const srcToken = intent.srcToken; - const dstToken = intent.dstToken; - const srcAmountBigInt = BigInt(intent.srcAmount); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dstPriceUSD: number = (dstToken as any)?.priceUSD ?? 1; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const srcPriceUSD: number = (srcToken as any)?.priceUSD ?? dstPriceUSD; - - const quotes = solvers - .map((solver) => { - const totalFills = solver.fillsCompleted + solver.fillsFailed; - const successRate = totalFills > 0 ? solver.fillsCompleted / totalFills : 0.5; - const fillCountScore = Math.min(solver.fillsCompleted / 100, 1); - const perfScore = successRate * 0.7 + fillCountScore * 0.3; - const variancePct = (1 - perfScore) * 0.008; - const varianceScaled = Math.round(1000 * (1 - variancePct)); - const dstAmount = (srcAmountBigInt * BigInt(varianceScaled)) / BigInt(1000); - const fee = (dstAmount * BigInt(5)) / BigInt(10000); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const feeUnits = Number(fee) / Math.pow(10, (dstToken as any)?.decimals ?? 7); - const totalFeesUSD = feeUnits * dstPriceUSD; - const srcUnits = Number(srcAmountBigInt) / Math.pow(10, srcToken?.decimals ?? 7); - const dstUnits = Number(dstAmount) / Math.pow(10, dstToken?.decimals ?? 7); - const priceImpact = - srcPriceUSD > 0 && dstPriceUSD > 0 - ? Math.max(0, 1 - (dstUnits * dstPriceUSD) / (srcUnits * srcPriceUSD)) - : 0; - - const dstTokenInfo = { - address: dstToken?.contract ?? "", - symbol: dstToken?.symbol ?? "", - name: dstToken?.symbol ?? "", - decimals: dstToken?.decimals ?? 7, - chain: "stellar" as SupportedChain, - priceUSD: dstToken?.priceUSD, - }; - - const route = this.routingService.buildRoute(srcToken, dstTokenInfo, solver.address, { - totalFeesUSD, - priceImpact, - estimatedFillTime: solver.avgFillTime + Math.floor(Math.random() * 30), - }); - - return { - solver: solver.address, - solverName: solver.name, - dstAmount: dstAmount.toString(), - fee: fee.toString(), - fillTime: solver.avgFillTime + Math.floor(Math.random() * 30), - expiresAt: Math.floor(Date.now() / 1000) + 60, - totalFeesUSD, - priceImpact, - route, - }; - }) - // nosemgrep: no-number-money -- sort comparator on bounded quote diffs only; amounts stay strings elsewhere. - .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); - - if (quotes.length > 0) { - await this.intentsService.update(id, { quotedDstAmount: quotes[0].dstAmount }); - } - - const best = quotes[0] ?? null; - return { - quotes, - bestQuote: best, - srcChain: intent.srcChain, - srcTokenSymbol: srcToken?.symbol ?? "", - srcAmount: intent.srcAmount, - dstTokenSymbol: dstToken?.symbol ?? "", - estimatedFillTime: best?.fillTime ?? 0, - totalFeesUSD: best?.totalFeesUSD ?? 0, - priceImpact: best?.priceImpact ?? 0, - }; - } -} diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index 8f677a56..e69de29b 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -1,829 +0,0 @@ -import { OnModuleDestroy, Optional } from "@nestjs/common"; -import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets"; -import { WebSocket } from "ws"; -import { IntentsService } from "./intents.service"; -import { SolversService } from "../solvers/solvers.service"; -import { MetricsService } from "../metrics/metrics.service"; -import { logger } from "../common/logger"; -import { SUPPORTED_CHAINS, SupportedChain } from "./intents.types"; -import { verifyStellarSignature, buildWsAuthMessage } from "../common/stellar-signature"; -import { buildMatchPredicate, IntentCapabilityIndex, SolverMatchPredicate } from "./solver-intent-matcher"; -import { - WS_MAX_FILTER_CHAINS, - WS_MAX_SUBSCRIPTIONS_PER_CONNECTION, -} from "../config/limits.config"; - -const HEARTBEAT_INTERVAL_MS = 30_000; - -/** - * How many sequenced events to keep in the replay buffer. - * - * At typical broadcast volume (a few dozen events/minute in production), - * 500 events covers many minutes of missed events — more than enough to - * bridge a transient network blip or container restart without forcing a - * full snapshot re-fetch. Increasing this beyond ~1 000 starts to add - * non-trivial heap pressure for large event payloads; the current bound - * is a deliberate memory vs. reconnect-gap tradeoff. - */ -const REPLAY_BUFFER_SIZE = 500; - -export interface SequencedEvent { - seq: number; - type: string; - [key: string]: unknown; -} - -/** - * Per-subscriber filter (issue #436). - * - * `chains` — explicit chain subscription set (`null` = unfiltered full feed). - * `solver` — capability predicate compiled from the authenticated solver's - * SolverRecord. Non-null only for connections that have completed - * the `auth` handshake. - * `wantAll` — when `true` (sent via `{ type: "subscribe", all: true }`), the - * solver opts out of capability filtering and receives the full - * feed regardless of its chain/token support — useful for - * analytics consumers. - */ -interface SubscriberFilter { - chains: Set | null; - /** Compiled solver capability predicate (null = not authenticated). */ - solver: SolverMatchPredicate | null; - /** Opt-out flag: receives all events even after authentication. */ - wantAll: boolean; - /** Number of `subscribe` messages this connection has sent. */ - subscriptionCount: number; -} - -/** - * Fixed-size ring buffer that retains the last `capacity` events so - * reconnecting clients can request a replay from a known sequence number. - */ -export class EventRingBuffer { - private readonly buf: SequencedEvent[] = []; - private readonly capacity: number; - - constructor(capacity = REPLAY_BUFFER_SIZE) { - this.capacity = capacity; - } - - push(event: SequencedEvent): void { - if (this.buf.length >= this.capacity) { - this.buf.shift(); - } - this.buf.push(event); - } - - /** - * Return all buffered events whose seq is strictly greater than `fromSeq`. - * Returns an empty array when `fromSeq` is older than the earliest buffered - * event (the caller should request a fresh snapshot instead). - */ - since(fromSeq: number): SequencedEvent[] { - return this.buf.filter((e) => e.seq > fromSeq); - } - - /** Lowest seq still in the buffer, or -1 when empty. */ - oldestSeq(): number { - return this.buf.length === 0 ? -1 : this.buf[0].seq; - } - - /** Highest seq in the buffer, or 0 when empty. */ - latestSeq(): number { - return this.buf.length === 0 ? 0 : this.buf[this.buf.length - 1].seq; - } - - size(): number { - return this.buf.length; - } -} - -/** - * Authentication / access-control decision (issue #49, updated #436) - * ───────────────────────────────────────────────────────────────────── - * The intent feed is intentionally PUBLIC and READ-ONLY for all clients. - * - * Solver bots that authenticate via `{ type: "auth", ... }` receive an - * *auto-scoped* feed: only intents matching their supported chains / tokens - * and with a non-zero bond requirement are delivered. This reduces noise and - * bandwidth as the solver set grows (O(solvers × intents) → O(solvers × matching-intents)). - * - * Opt-out: `{ type: "subscribe", all: true }` returns the full unfiltered feed - * regardless of authentication — designed for analytics / monitoring consumers. - * - * Solver bots submit intents and accept/fill them through the authenticated - * REST API. The WS gateway never accepts writes. - */ -@WebSocketGateway({ path: "/ws" }) -export class IntentsGateway - implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy -{ - /** - * Map from WebSocket client to its per-connection subscription filter. - */ - private readonly subscribers = new Map(); - private readonly alive = new WeakMap(); - private readonly authenticatedSolver = new WeakMap(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private heartbeatTimer: any; - private nextSeq = 1; - private readonly backplane: null | { - publish: (event: Record) => void; - subscribe: (handler: (event: Record) => void) => void; - } = null; - - /** Ring buffer storing the last REPLAY_BUFFER_SIZE broadcast events. */ - private readonly ringBuffer = new EventRingBuffer(REPLAY_BUFFER_SIZE); - - constructor( - private readonly intentsService: IntentsService, - private readonly solversService: SolversService, - private readonly intentIndex: IntentCapabilityIndex, - @Optional() private readonly metricsService?: MetricsService, - ) { - this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS); - this.backplane = this.createBackplane(); - if (this.backplane) { - this.backplane.subscribe((event) => { - const type = typeof event.type === "string" ? event.type : ""; - if (!type) return; - this.dispatchRemoteEvent(event as Record); - }); - } - logger.info("ws heartbeat started"); - } - - private createBackplane(): null | { - publish: (event: Record) => void; - subscribe: (handler: (event: Record) => void) => void; - } { - const mode = (process.env.WS_BACKPLANE ?? "memory").toLowerCase(); - if (mode !== "redis") return null; - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports - const redis = require("redis"); - if (!redis?.createClient) { - logger.warn("WS_BACKPLANE=redis but the redis package is not available; falling back to memory"); - return null; - } - - const client = redis.createClient({ url: process.env.REDIS_URL ?? "redis://localhost:6379" }); - const channel = "vortex:intents:ws"; - const pub = client; - const sub = client.duplicate(); - - void sub.connect(); - void sub.subscribe(channel, (message: string) => { - try { - const event = JSON.parse(message) as Record; - if (event && typeof event === "object") { - this.dispatchRemoteEvent(event); - } - } catch { - // Ignore malformed backplane payloads. - } - }); - - return { - publish: (event: Record) => { - void pub.publish(channel, JSON.stringify(event)); - }, - subscribe: (handler: (event: Record) => void) => { - void sub.subscribe(channel, (message: string) => { - try { - const event = JSON.parse(message) as Record; - handler(event); - } catch { - // Ignore malformed backplane payloads. - } - }); - }, - }; - } catch { - logger.warn("WS_BACKPLANE=redis but the redis package is not available; falling back to memory"); - return null; - } - } - - private static isSupportedChain(value: unknown): value is SupportedChain { - return typeof value === "string" && (SUPPORTED_CHAINS as readonly string[]).includes(value); - } - - private dispatchRemoteEvent(event: Record) { - const type = typeof event.type === "string" ? event.type : ""; - if (!type || type === "connected" || type === "snapshot" || type === "subscribed") return; - - const payload = JSON.stringify(event); - const chain = this.getEventChainSync(event as { type: string; [key: string]: unknown }); - this.deliverToMatchingSubscribers(payload, chain, event as { type: string; [key: string]: unknown }); - } - - /** - * Synchronous chain resolution for simple cases (used by dispatchRemoteEvent). - * Reads srcChain directly from the event or its inlined intent object. - */ - private getEventChainSync(event: { type: string; [key: string]: unknown }): SupportedChain | null { - const intent = (event as { intent?: { srcChain?: unknown } }).intent; - if (intent && typeof intent.srcChain === "string" && IntentsGateway.isSupportedChain(intent.srcChain)) { - return intent.srcChain; - } - - const srcChain = (event as { srcChain?: unknown }).srcChain; - if (typeof srcChain === "string" && IntentsGateway.isSupportedChain(srcChain)) { - return srcChain; - } - - return null; - } - - /** - * Deliver a pre-serialised event payload to every matching subscriber. - * - * Delivery rules (evaluated in order): - * 1. Client is not OPEN → skip. - * 2. Client set wantAll=true → always deliver. - * 3. Client has a solver capability predicate: - * a. Event carries an inlined intent → apply predicate to that intent. - * b. Event is a state-transition (only intentId available) → deliver - * (we cannot efficiently look up the intent here; the solver would - * already have received the intent_created event through the filter). - * 4. Client has a plain chain filter (`chains != null`) → apply chain match. - * 5. No filter → full unfiltered feed (backward-compatible default). - */ - private deliverToMatchingSubscribers( - payload: string, - chain: SupportedChain | null, - event: { type: string; [key: string]: unknown }, - ) { - for (const [client, filter] of this.subscribers) { - if (client.readyState !== WebSocket.OPEN) continue; - - // Opt-out: solver requested full feed. - if (filter.wantAll) { - client.send(payload); - continue; - } - - // Authenticated solver — apply capability predicate. - if (filter.solver !== null) { - const solverPredicate = filter.solver; - const inlinedIntent = (event as { intent?: unknown }).intent; - - // intent_created carries a full intent object we can test directly. - if (event.type === "intent_created" && inlinedIntent && typeof inlinedIntent === "object") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const matches = solverPredicate.matches(inlinedIntent as any); - if (matches) { - client.send(payload); - try { this.metricsService?.incWsDelivered(solverPredicate.solverAddress); } catch { /* noop */ } - } else { - try { this.metricsService?.incWsFiltered(solverPredicate.solverAddress); } catch { /* noop */ } - } - continue; - } - - // State-transition events: the solver already filtered on intent_created, - // so we pass them through to keep the feed self-consistent. - client.send(payload); - try { this.metricsService?.incWsDelivered(solverPredicate.solverAddress); } catch { /* noop */ } - continue; - } - - // No filter set → full unfiltered feed (backward-compatible default). - if (filter.chains === null) { - client.send(payload); - continue; - } - - // Chain couldn't be resolved → deliver to everyone (safe default). - if (chain === null) { - client.send(payload); - continue; - } - - // Only send if the event's chain is in this subscriber's filter. - if (filter.chains.has(chain)) { - client.send(payload); - } - } - } - - handleConnection(client: WebSocket) { - this.subscribers.set(client, { - chains: null, - solver: null, - wantAll: false, - subscriptionCount: 0, - }); - this.alive.set(client, true); - this.metricsService?.incWsConnection(); - - client.on("message", (raw) => { - void this.handleMessage(client, raw); - }); - - client.on("pong", () => { - this.alive.set(client, true); - }); - - client.on("error", () => { - this.removeSubscriber(client); - logger.debug( - `ws client error/drop — active subscribers: ${this.subscribers.size}`, - ); - }); - - const currentSeq = this.nextSeq - 1; - - client.send( - JSON.stringify({ - type: "connected", - message: "Vortex intent stream", - seq: currentSeq, - }), - ); - - // Send the initial snapshot asynchronously — the client receives it - // immediately after the "connected" message. - Promise.resolve(this.intentsService.getByState("open")) - .then((open) => { - client.send(JSON.stringify({ type: "snapshot", intents: open.slice(0, 20), seq: currentSeq })); - }) - .catch(() => { - /* snapshot failure is non-fatal — client can re-fetch via REST */ - }); - - logger.info(`ws client connected (subscribers=${this.subscribers.size})`); - } - - handleDisconnect(client: WebSocket) { - this.removeSubscriber(client); - logger.info(`ws client disconnected (subscribers=${this.subscribers.size})`); - } - - /** - * Drop a client from the subscriber set and keep the connection gauge honest. - * - * Every path that removes a client goes through here — explicit disconnect, - * a transport-level `error`, and the heartbeat terminator — because they are - * mutually exclusive in practice but not in the platform: a socket that - * errors frequently never reaches `handleDisconnect`, and one that dies - * silently is only reaped by the heartbeat. Removing a client from two - * places with a bare `subscribers.delete` would leak - * `vortex_ws_connections_active` upwards until the process restarts, and a - * gauge that only ever climbs turns the WS panels into decoration. - * - * The gauge is decremented only when this call actually removed something, so - * a duplicate disconnect cannot drive it negative. - */ - private removeSubscriber(client: WebSocket): void { - const removed = this.subscribers.delete(client); - this.authenticatedSolver.delete(client); - this.alive.delete(client); - if (removed) this.metricsService?.decWsConnection(); - } - - /** - * Handle a single incoming WebSocket message from a client. - * - * Supported message types: - * - `{ type: "subscribe", chains?: string[], all?: boolean }` — set a - * per-connection filter or opt out of capability filtering with `all: true`. - * - `{ type: "replay", fromSeq: number }` — replay buffered events. - * - `{ type: "auth", solver, timestamp, signature }` — authenticate as a - * registered solver; installs a capability predicate and sends an - * auto-scoped snapshot of currently-eligible open intents. - * - * Unknown types and malformed messages are silently ignored. - */ - private async handleMessage(client: WebSocket, raw: import("ws").RawData): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(raw.toString()); - } catch { - return; - } - - if (typeof parsed !== "object" || parsed === null) return; - - const msg = parsed as Record; - - switch (msg.type) { - case "subscribe": - this.handleSubscribe(client, msg); - break; - case "replay": - this.handleReplay(client, msg); - break; - case "auth": - await this.handleAuth(client, msg); - break; - default: - break; - } - } - - /** - * Process a `{ type: "subscribe", chains?: string[], all?: boolean }` message. - * - * When `all: true` is present, the connection opts out of capability filtering - * and receives the complete unfiltered feed regardless of solver auth status. - * - * When `chains` is present, a per-connection chain filter is installed (this - * clears any existing solver capability predicate on the connection). - * Validates each chain value against `SUPPORTED_CHAINS` and stores only - * the valid subset. A subscribe message with no valid chains is treated as - * "subscribe to nothing" (the client will receive only chainless events). - * An entirely missing or non-array `chains` field is rejected silently - * without updating the existing filter. - * - * Issue #476: enforces two per-connection limits: - * 1. The `chains` array may contain at most `WS_MAX_FILTER_CHAINS` values. - * 2. A connection may send at most `WS_MAX_SUBSCRIPTIONS_PER_CONNECTION` - * subscribe messages in its lifetime. Excess subscribe attempts are - * rejected with a `subscribe_rejected` error frame. - */ - private handleSubscribe(client: WebSocket, msg: Record): void { - // all=true: opt out of capability filtering. - if (msg.all === true) { - const existing = this.subscribers.get(client) ?? { - chains: null, - solver: null, - wantAll: false, - subscriptionCount: 0, - }; - this.subscribers.set(client, { ...existing, wantAll: true }); - logger.debug("ws client opted out of capability filtering (all=true)"); - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ type: "subscribed", filter: { all: true } })); - } - return; - } - - if (!Array.isArray(msg.chains)) { - logger.debug("ws subscribe ignored: chains field missing or not an array"); - return; - } - - const filter = this.subscribers.get(client); - if (!filter) return; - - // ── Limit 1: max subscriptions per connection (issue #476) ─────────────── - const maxSubs = parseInt( - process.env.WS_MAX_SUBSCRIPTIONS ?? String(WS_MAX_SUBSCRIPTIONS_PER_CONNECTION), - 10, - ); - if (filter.subscriptionCount >= maxSubs) { - logger.warn( - `ws subscribe_rejected: connection has reached the max subscription limit (${maxSubs})`, - ); - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "subscribe_rejected", - reason: `Maximum subscription limit of ${maxSubs} reached for this connection`, - }), - ); - } - return; - } - - // ── Limit 2: max chain-filter values per subscribe message (issue #476) ── - const maxChains = parseInt( - process.env.WS_MAX_FILTER_CHAINS ?? String(WS_MAX_FILTER_CHAINS), - 10, - ); - const rawChains = msg.chains as unknown[]; - if (rawChains.length > maxChains) { - logger.warn( - `ws subscribe_rejected: chains array length ${rawChains.length} exceeds max ${maxChains}`, - ); - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "subscribe_rejected", - reason: `chains array may contain at most ${maxChains} values`, - }), - ); - } - return; - } - - const validChains = rawChains.filter( - (c): c is SupportedChain => - typeof c === "string" && (SUPPORTED_CHAINS as readonly string[]).includes(c), - ); - - filter.chains = new Set(validChains); - filter.subscriptionCount += 1; - - logger.debug(`ws client subscribed to chains: ${validChains.join(", ") || "(none)"}`); - - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "subscribed", - filter: { chains: validChains }, - }), - ); - } - } - - /** - * Process a `{ type: "replay", fromSeq: number }` message. - */ - private handleReplay(client: WebSocket, msg: Record): void { - const fromSeq = typeof msg.fromSeq === "number" ? msg.fromSeq : null; - if (fromSeq === null || !Number.isInteger(fromSeq) || fromSeq < 0) { - logger.debug("ws replay ignored: fromSeq missing or invalid"); - return; - } - - if (client.readyState !== WebSocket.OPEN) return; - - const oldest = this.ringBuffer.oldestSeq(); - - if (oldest !== -1 && fromSeq < oldest - 1) { - client.send( - JSON.stringify({ - type: "replay_too_old", - fromSeq, - oldestAvailableSeq: oldest, - }), - ); - logger.debug(`ws replay_too_old: fromSeq=${fromSeq} oldestAvailable=${oldest}`); - return; - } - - const events = this.ringBuffer.since(fromSeq); - - client.send( - JSON.stringify({ - type: "replay_start", - fromSeq, - count: events.length, - }), - ); - - for (const event of events) { - if (client.readyState !== WebSocket.OPEN) break; - client.send(JSON.stringify(event)); - } - - if (client.readyState === WebSocket.OPEN) { - client.send( - JSON.stringify({ - type: "replay_end", - count: events.length, - }), - ); - } - - logger.debug(`ws replay complete: fromSeq=${fromSeq} count=${events.length}`); - } - - /** - * Authenticate a solver connection and install a capability predicate. - * - * On success: - * 1. Compiles a per-solver match predicate from the solver's SolverRecord. - * 2. Installs it on the subscriber filter so future broadcasts are scoped. - * 3. Sends an `auth_ok` frame. - * 4. Immediately sends a scoped `eligible_snapshot` with currently-eligible - * open intents from the in-memory index — so the solver doesn't need to - * separately call GET /solvers/:address/eligible-intents after auth. - * - * Capability updates (e.g. bond changes ingested via event-ingestion) call - * `updateSolverPredicate()` directly — no reconnect required. - */ - private async handleAuth(client: WebSocket, payload: Record) { - const solver = typeof payload.solver === "string" ? payload.solver : ""; - const timestamp = payload.timestamp; - const signature = typeof payload.signature === "string" ? payload.signature : ""; - - if (!solver || !signature || typeof timestamp !== "number") { - client.send(JSON.stringify({ type: "auth_error", reason: "auth payload requires solver, timestamp, and signature" })); - return; - } - - const now = Math.floor(Date.now() / 1000); - const skew = Math.abs(now - timestamp); - if (skew > 300) { - client.send(JSON.stringify({ type: "auth_error", reason: "stale or future auth timestamp" })); - return; - } - - const solverRecord = await this.solversService.get(solver); - if (!solverRecord || !solverRecord.isActive) { - client.send(JSON.stringify({ type: "auth_error", reason: "solver not registered or inactive" })); - return; - } - - try { - verifyStellarSignature(solver, buildWsAuthMessage(solver, timestamp), signature); - } catch { - client.send(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); - return; - } - - // Build capability predicate and store it on the connection. - const predicate = buildMatchPredicate(solverRecord); - this.authenticatedSolver.set(client, solver); - const authFilter = this.subscribers.get(client); - this.subscribers.set(client, { - chains: authFilter?.chains ?? null, - solver: predicate, - wantAll: authFilter?.wantAll ?? false, - subscriptionCount: authFilter?.subscriptionCount ?? 0, - }); - - client.send(JSON.stringify({ type: "auth_ok" })); - - // Send scoped snapshot of currently-eligible intents (issue #436). - try { - const eligible = this.intentIndex.getEligibleFor(solverRecord); - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify({ - type: "eligible_snapshot", - intents: eligible, - count: eligible.length, - })); - } - } catch { - // Non-fatal — solver can fall back to GET /solvers/:address/eligible-intents. - } - - logger.info(`ws solver auth ok: address=${solver} chains=${solverRecord.supportedChains.join(",")} tokens=${solverRecord.supportedTokens.join(",")}`); - } - - /** - * Update the capability predicate for all live connections authenticated as - * the given solver address. - * - * Called by EventIngestionService when a BondDeposited / BondWithdrawn / - * SolverRegistered event updates a solver's capabilities — no reconnect needed. - */ - async updateSolverPredicate(solverAddress: string): Promise { - const solverRecord = await this.solversService.get(solverAddress); - if (!solverRecord) return; - - const predicate = buildMatchPredicate(solverRecord); - for (const [client, filter] of this.subscribers) { - if (this.authenticatedSolver.get(client) === solverAddress && filter.solver !== null) { - this.subscribers.set(client, { ...filter, solver: predicate }); - } - } - - logger.debug(`ws solver predicate updated for ${solverAddress}`); - } - - /** - * Resolve the source chain for an event payload. - */ - private async getEventChain( - event: { type: string; [key: string]: unknown }, - ): Promise { - if (event.type === "intent_created") { - const intent = event.intent as { srcChain?: string } | undefined; - const chain = intent?.srcChain; - if (chain && (SUPPORTED_CHAINS as readonly string[]).includes(chain)) { - return chain as SupportedChain; - } - return null; - } - - const lookupTypes = new Set([ - "intent_accepted", - "intent_filled", - "intent_cancelled", - "intent_expired", - "intent_slashed", - ]); - - if (lookupTypes.has(event.type)) { - const intentId = typeof event.intentId === "string" ? event.intentId : null; - if (!intentId) return null; - - try { - const intent = await this.intentsService.get(intentId); - if (intent && (SUPPORTED_CHAINS as readonly string[]).includes(intent.srcChain)) { - return intent.srcChain as SupportedChain; - } - } catch { - // Lookup failure is non-fatal — deliver to all subscribers. - } - return null; - } - - return null; - } - - /** - * Assign a monotonically increasing sequence number, push the event into - * the ring buffer, then deliver it to every subscriber whose filter matches. - * - * For authenticated solvers without `all=true`, only intents matching their - * capability predicate are delivered. State-transition events (no inlined - * intent) are always delivered to authenticated subscribers. - * - * Side-effects: - * - Updates the intent index for `intent_created` (add) and terminal-state - * events (remove), keeping the capability index fresh without a rebuild. - */ - async broadcast(event: { type: string; [key: string]: unknown }): Promise { - const enqueuedAt = Date.now(); - const seq = this.nextSeq++; - const sequencedEvent: SequencedEvent = { ...event, seq }; - - // Update the capability index before delivery so a racing replay or - // eligible-intents call sees fresh state. - this.updateIndexForEvent(event); - - // Push into replay buffer before sending. - this.ringBuffer.push(sequencedEvent); - - logger.debug(`ws broadcast type=${event.type} seq=${seq} subscribers=${this.subscribers.size}`); - - if (this.backplane) { - this.backplane.publish(sequencedEvent as Record); - } - - // Resolve the chain once — shared across all subscriber checks. - const eventChain = await this.getEventChain(event); - - const payload = JSON.stringify(sequencedEvent); - this.deliverToMatchingSubscribers(payload, eventChain, event); - - try { - this.metricsService?.observeWsDelivery((Date.now() - enqueuedAt) / 1000); - } catch { - // Metrics must never break broadcasts. - } - } - - /** Keep the IntentCapabilityIndex in sync with broadcast events. */ - private updateIndexForEvent(event: { type: string; [key: string]: unknown }): void { - try { - if (event.type === "intent_created") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const intent = (event as any).intent; - if (intent) this.intentIndex.addIntent(intent); - } else if ( - event.type === "intent_accepted" || - event.type === "intent_filled" || - event.type === "intent_cancelled" || - event.type === "intent_expired" || - event.type === "intent_slashed" - ) { - const intentId = typeof event.intentId === "string" ? event.intentId : null; - if (intentId) this.intentIndex.removeIntent(intentId); - } - } catch { - // Index update is best-effort — never break broadcasts. - } - } - - getAliveCount(): number { - let count = 0; - for (const client of this.subscribers.keys()) { - if (this.alive.get(client) === true) count++; - } - return count; - } - - getSubscriberCount(): number { - return this.subscribers.size; - } - - /** Returns the current number of active WebSocket subscribers. */ - get subscriberCount(): number { - return this.subscribers.size; - } - - private heartbeat() { - for (const [client] of this.subscribers) { - if (this.alive.get(client) === false) { - client.terminate(); - this.removeSubscriber(client); - logger.debug( - `ws heartbeat terminated dead client (subscribers=${this.subscribers.size})`, - ); - continue; - } - - this.alive.set(client, false); - if (client.readyState === WebSocket.OPEN) { - client.ping(); - } - } - } - - onModuleDestroy() { - if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); - for (const [client] of this.subscribers) { - client.close(1001, "Server shutting down"); - this.removeSubscriber(client); - } - } -} diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index fda6e45b..e69de29b 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -1,824 +0,0 @@ -import { - Inject, - Injectable, - Logger, - Optional, - ServiceUnavailableException, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { v4 as uuidv4 } from "uuid"; -import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; -import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; -import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; -import { AppConfig } from "../config/configuration"; -import { - CHAIN_DEADLINE_DEFAULTS, - DEFAULT_DEADLINE_SECONDS, - CHAIN_FILL_WINDOW_DEFAULTS, - DEFAULT_FILL_WINDOW_SECONDS, -} from "../config/configuration"; -import { StellarTxService } from "../soroban/stellar-tx.service"; -import { ShadowService, type ShadowObservationRequest } from "../soroban/shadow.service"; -import { SHADOW_TRANSITIONS, type ShadowTransition } from "../soroban/shadow.types"; -import { MetricsService } from "../metrics/metrics.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { ProtocolParamsService } from "../governance/params.service"; -import { FeatureFlagService } from "../flags/feature-flag.service"; - -const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slashed"]; - -/** - * Sentinel `from_state` for the transition into "open". - * - * Not an {@link IntentState}: creation has no prior state, and inventing one - * would put a value in the `from_state` label that no lifecycle edge can - * produce. Bounded (one extra series), and it keeps the funnel's denominator - * honest. - */ -const NONE_STATE = "none"; - -/** - * Runtime check that `transition` is one of the five the shadow monitor models. - * - * A mis-wired call site is logged and dropped rather than thrown on, so a shadow - * bug can never become a 500 on the intent path, and so an unknown label can - * never create a new Prometheus series. - */ -function isKnownShadowTransition(transition: ShadowTransition): boolean { - return (SHADOW_TRANSITIONS as readonly string[]).includes(transition); -} - -/** How long a completed idempotency-key result stays replayable. */ -const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours - -/** - * Maximum number of simultaneously open (state = "open" | "accepted") intents - * allowed per user address. - * - * Rationale: the per-user rate limit (UserThrottlerGuard) bounds the *rate* of - * creation but not the standing *count* — a user could steadily accumulate - * thousands of open intents over time, which is exactly the scenario the - * on-call runbook flags as a sweeper-performance risk. This constant is the - * authoritative cap; it is enforced in IntentsController.create() before the - * intent is persisted. - * - * Kept as a named constant (rather than a config value) so the cap is visible - * at the call site and testable without ConfigService. Raise or lower it with - * a code change + review rather than a silent env-var override. - */ -export const MAX_OPEN_INTENTS_PER_USER = 50; - -/** Payload for creating a new intent. */ -export type NewIntentData = Omit; - -/** - * Orchestration layer for intents. - * - * Business logic (ID generation, default state, deadline defaulting, - * idempotency cache, audit log) lives here. All persistence is delegated - * to the injected IIntentsRepository so the storage adapter can be swapped - * (in-memory ↔ Prisma) without touching this service or anything above it. - */ -@Injectable() -export class IntentsService { - private readonly logger = new Logger(IntentsService.name); - - /** - * Idempotency cache: maps caller-supplied keys → { intentId, expiresAt }. - * Kept in-service (not in the repository) because it is a short-lived - * request deduplication concern, not a durable persistence concern. - */ - private readonly idempotencyCache = new Map(); - - /** - * Keys whose creation is currently in flight → the in-flight creation - * promise. Claimed synchronously in {@link create} so that concurrent - * requests carrying the same idempotency key collapse onto a single created - * intent instead of racing the check-then-set window (issue #274). - */ - private readonly idempotencyInFlight = new Map>(); - - /** - * In-memory audit log used as a fast read path and fallback when the DB is - * unavailable. The canonical source of truth is the intent_audit_log table - * (issue #217 / #62). Writes are fire-and-forget against PrismaService so a - * DB write failure never blocks or rolls back the underlying state transition. - */ - private readonly auditLog = new Map(); - - constructor( - @Inject(INTENTS_REPOSITORY) - private readonly repo: IIntentsRepository, - private readonly configService: ConfigService, - private readonly stellarTxService: StellarTxService, - private readonly prisma: PrismaService, - private readonly protocolParamsService: ProtocolParamsService, - /** - * Shadow-mode divergence monitor (issue #401). - * - * Injected `@Optional()` on purpose: the monitor is observability, not a - * correctness dependency, and the intent path must keep working — including - * in the unit-test harnesses that construct this service directly — when - * the soroban module is not in the graph. - */ - @Optional() private readonly shadowService?: ShadowService, - /** - * SLO counters for the intent funnel (issue #481). - * - * `@Optional()` for the same reason as the shadow monitor: the dashboards - * are observability, and a unit harness that constructs this service - * directly must not have to provide a metrics registry. `MetricsModule` is - * `@Global()` and registered in `AppModule`, so in the running application - * this is always present. - */ - @Optional() private readonly metricsService?: MetricsService, - @Optional() private readonly flags?: FeatureFlagService, - ) {} - - /** - * Logs the store size and evicts stale terminal intents from the in-memory - * adapter when it is the active backend. This keeps the memory footprint - * bounded without affecting on-chain or durable storage paths. - * - * Runs as the `intents.store-size` background job (see - * intents-maintenance.jobs.ts, issue #494) rather than a local timer. - */ - async logStoreSize(): Promise { - const evicted = await this.evictTerminalIntents(); - const remaining = await this.repo.findAll(); - this.logger.log(`[store-monitor] intents store size: ${remaining.length} (evicted=${evicted})`); - } - - private async evictTerminalIntents(): Promise { - const persistence = process.env.INTENTS_PERSISTENCE ?? "memory"; - const onchainEnabled = this.configService.get("onchainIntentsEnabled", { infer: true }); - if (persistence !== "memory" || onchainEnabled) { - return 0; - } - - const retentionDays = Number(this.configService.get("intentRetentionDays", { infer: true }) ?? 30); - const retentionSeconds = Math.max(0, Number.isFinite(retentionDays) ? retentionDays * 86400 : 30 * 86400); - const cutoff = Math.floor(Date.now() / 1000) - retentionSeconds; - - const all = await this.repo.findAll(); - const stale = all.filter((intent) => { - if (!TERMINAL_STATES.includes(intent.state)) return false; - const lastTerminalTs = intent.filledAt ?? intent.createdAt; - return lastTerminalTs <= cutoff; - }); - - let evicted = 0; - for (const intent of stale) { - const removed = await this.repo.delete(intent.intentId); - if (removed) evicted += 1; - this.logger.warn( - `[retention] evicted terminal intent ${intent.intentId} from in-memory store (state=${intent.state}, createdAt=${intent.createdAt})`, - ); - } - - return evicted; - } - - async create( - data: Omit, - idempotencyKey?: string, - ): Promise { - if (!idempotencyKey) { - return this.persistNewIntent(data); - } - - const now = Math.floor(Date.now() / 1000); - - // 1. Fast path — a previous request with this key already completed. - const cached = this.idempotencyCache.get(idempotencyKey); - if (cached && cached.expiresAt > now) { - const cachedIntent = await this.repo.findById(cached.intentId); - if (cachedIntent) { - return cachedIntent; - } - // Cache entry outlived its intent — drop it and fall through. - this.idempotencyCache.delete(idempotencyKey); - } - - // 2. Race-safe claim. The check-and-set on `idempotencyInFlight` runs - // synchronously — there is no `await` between the `get` and the `set` — - // so two concurrent callers carrying the same key can never both proceed - // to create. The loser awaits the winner's in-flight promise and returns - // its result. The claim is taken *before* the conditional - // `registerOnChain()` await inside persistNewIntent(), so the race window - // is closed rather than merely shifted past the on-chain call. - // - // The future Prisma-backed adapter (issue #1) must preserve the same - // guarantee at the storage layer: an atomic - // `INSERT ... ON CONFLICT (idempotency_key) DO NOTHING` followed by a - // read-back of the winning row, rather than a read-then-write. - const inFlight = this.idempotencyInFlight.get(idempotencyKey); - if (inFlight) { - return inFlight; - } - - const creation = this.persistNewIntent(data) - .then((intent) => { - this.idempotencyCache.set(idempotencyKey, { - intentId: intent.intentId, - expiresAt: now + IDEMPOTENCY_TTL_SECONDS, - }); - return intent; - }) - .finally(() => { - this.idempotencyInFlight.delete(idempotencyKey); - }); - - this.idempotencyInFlight.set(idempotencyKey, creation); - return creation; - } - - /** - * Issue #429 — Atomically create up to N intents (all-or-nothing). - * If any intent fails validation or user open-intent limits, NO intents are created - * and per-item validation errors are returned. - */ - async createBatch( - items: NewIntentData[], - ): Promise<{ created: Intent[]; errors: { index: number; field?: string; message: string }[] }> { - const errors: { index: number; field?: string; message: string }[] = []; - const userOpenCounts = new Map(); - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const user = item.user?.toLowerCase(); - - if (!user) { - errors.push({ index: i, field: "user", message: "User address is required" }); - continue; - } - - if (!userOpenCounts.has(user)) { - const standingCount = await this.countOpenByUser(item.user); - userOpenCounts.set(user, standingCount); - } - - const currentCount = userOpenCounts.get(user)!; - if (currentCount + 1 > MAX_OPEN_INTENTS_PER_USER) { - errors.push({ - index: i, - field: "user", - message: `Open-intent cap reached — max ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents per user`, - }); - } else { - userOpenCounts.set(user, currentCount + 1); - } - } - - if (errors.length > 0) { - return { created: [], errors }; - } - - const created: Intent[] = []; - for (const item of items) { - const intent = await this.persistNewIntent(item); - created.push(intent); - } - - return { created, errors: [] }; - } - - /** - * Build, optionally register on-chain, and persist a brand-new intent. - * Contains no idempotency logic — deduplication is the caller's concern. - */ - private async persistNewIntent( - data: Omit, - ): Promise { - const now = Math.floor(Date.now() / 1000); - - // Snapshot governance-controlled parameters at creation time so in-flight - // intents are evaluated against the rules that were active when the user - // submitted (issue #500). - const paramsSnapshot = this.protocolParamsService.snapshotForChain(data.srcChain); - const defaultDeadline = data.deadline ?? now + paramsSnapshot.deadlineSeconds; - - const intent: Intent = { - ...data, - intentId: uuidv4(), - state: "open", - createdAt: now, - deadline: defaultDeadline, - paramsVersion: paramsSnapshot.version, - }; - - // ONCHAIN_INTENTS_ENABLED is the default; the `onchain-intents-enabled` - // runtime flag (issue #495) can roll it out per chain / percentage. - const onchain = this.flags - ? await this.flags.getBooleanValue("onchain-intents-enabled", { - targetingKey: intent.intentId, - chain: intent.srcChain, - }) - : this.configService.get("onchainIntentsEnabled", { infer: true }); - if (onchain) { - await this.registerOnChain(intent); - } - - await this.repo.save(intent); - // Creation is the entry edge of the funnel: the `vortex:intent:*` recording - // rules count transitions *into* each state, so without this the intent - // dashboard would start every conversion ratio from zero. `from_state` is - // the sentinel "none" — an intent that does not exist yet has no state. - this.countTransition(NONE_STATE, "open"); - return intent; - } - - /** - * Registers `intent` with the settlement contract. Only called when - * ONCHAIN_INTENTS_ENABLED is on; while that flag is off, create() stays - * fully in-memory (the rollout fallback). - */ - private async registerOnChain(intent: Intent): Promise { - const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); - if (!contractId) { - throw new ServiceUnavailableException( - "On-chain intent registration is enabled but SETTLEMENT_CONTRACT_ID is not configured", - ); - } - - try { - const result = await this.stellarTxService.invokeContract({ - contractId, - method: "create_intent", - args: this.buildCreateIntentArgs(intent), - }); - this.logger.log(`Registered intent ${intent.intentId} on-chain (tx ${result.hash})`); - } catch (err) { - this.logger.error( - `Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`, - ); - throw new ServiceUnavailableException( - "Failed to register intent with the settlement contract", - ); - } - } - - private buildCreateIntentArgs(intent: Intent): xdr.ScVal[] { - return [ - nativeToScVal(intent.intentId, { type: "string" }), - new Address(intent.user).toScVal(), - nativeToScVal(intent.srcChain, { type: "symbol" }), - nativeToScVal(intent.srcToken.address, { type: "string" }), - nativeToScVal(BigInt(intent.srcAmount), { type: "i128" }), - new Address(intent.dstToken.contract).toScVal(), - nativeToScVal(BigInt(intent.minDstAmount), { type: "i128" }), - nativeToScVal(intent.deadline, { type: "u64" }), - ]; - } - - // --------------------------------------------------------------------------- - // Shadow-mode divergence monitoring (issue #401) - // --------------------------------------------------------------------------- - // - // Every state transition the off-chain path commits is handed to - // ShadowService, which simulates the equivalent contract call on a background - // queue and records the (expected, simulated) pair. The call here is - // synchronous, allocation-light and never awaited — see the latency - // guarantee on ShadowService.observe. - // - // Both outcomes are reported, not just successes: a transition the off-chain - // path *refused* is the interesting negative case, because a contract that - // would have accepted it is a real divergence. - - /** - * Report one off-chain transition to the shadow monitor. - * - * Callers MUST gate on {@link beginShadowObservation} first: that is where - * the disabled check and the sampling draw happen, so a sampled-out - * transition costs one `Math.random()` and no repository I/O, no XDR encoding - * and no timer work. - * - * The whole body is wrapped: the monitor is observability, so a bug in it can - * never surface as a failed intent transition. - */ - private reportShadow( - transition: ShadowTransition, - intentId: string, - committed: boolean, - method: string, - args: xdr.ScVal[], - ): void { - try { - if (!this.shadowService) return; - if (!isKnownShadowTransition(transition)) { - // A mis-wired call site must be visible but must not throw into the - // request path, and must not create an unbounded Prometheus label. - this.logger.error(`[shadow] dropping observation with unknown transition "${transition}"`); - return; - } - const request: ShadowObservationRequest = { transition, intentId, committed, method, args }; - this.shadowService.observe(request); - } catch (err) { - this.logger.error(`[shadow] reportShadow failed, discarding: ${(err as Error).message}`); - } - } - - /** - * Ask the shadow monitor whether it wants to observe the transition that is - * about to happen, before any shadow-only work is done. - * - * Returns false when the monitor is absent, disabled, or has sampled this - * transition out. Sampling happens here rather than inside `observe()` so - * the extra repository read and XDR encoding the cancel/expire/slash hooks - * need are only paid for transitions that will actually be simulated. - */ - private beginShadowObservation(): boolean { - try { - return this.shadowService?.shouldObserve() === true; - } catch (err) { - this.logger.error(`[shadow] shouldObserve failed: ${(err as Error).message}`); - return false; - } - } - - /** - * Build the contract arguments for a transition, tolerating a record that - * cannot be encoded. - * - * A malformed intent (a non-integer amount, an unparseable address) must not - * be able to break the shadow path — the whole point of the monitor is to - * gather evidence, and an encoding failure is evidence in itself. It is - * therefore reported as an "empty" argument list, which simulates against the - * contract's arity check and surfaces as an `outcome_mismatch`. - */ - private safeArgs(build: () => xdr.ScVal[]): xdr.ScVal[] { - try { - return build(); - } catch (err) { - this.logger.warn( - `[shadow] could not encode contract args for simulation: ${(err as Error).message}`, - ); - return []; - } - } - - /** - * Count one committed lifecycle transition (issue #481). - * - * `vortex_intent_state_transitions_total{from_state,to_state}` is the only - * input to the `vortex:intent:*` recording rules, i.e. to the intent-funnel - * dashboard and to the `VortexIntentsNotTerminating` / - * `VortexSolverFillRateLow` alerts. It is counted here, once, immediately - * after the conditional write won — the same place the state actually moves, - * so a lost race is never counted. - */ - private countTransition(from: string, to: string): void { - try { - this.metricsService?.incIntentStateTransition(from, to); - } catch (err) { - this.logger.error(`[metrics] could not record transition ${from}->${to}: ${(err as Error).message}`); - } - } - - async get(id: string): Promise { - return this.repo.findById(id); - } - - async getAll(): Promise { - return this.repo.findAll(); - } - - async getByState(state: IntentState): Promise { - return this.repo.findByState(state); - } - - async getByUser(user: string): Promise { - return this.repo.findByUser(user); - } - - /** - * Batch-fetch the current record for each of `ids` (issue #275). - * - * IDs are de-duplicated; IDs with no matching record are simply omitted from - * the result (callers get "missing" by comparing lengths, not a 404 per ID). - * - * This reuses `get()` per ID rather than adding a storage-layer method — fine - * for the in-memory adapter. Issue #1's Prisma adapter should implement this - * as a single `WHERE intent_id IN (...)` query for efficiency. - */ - async getMany(ids: string[]): Promise { - const unique = [...new Set(ids)]; - const found = await Promise.all(unique.map((id) => this.get(id))); - return found.filter((intent): intent is Intent => intent !== undefined); - } - - async getAcceptedCountBySolver(solver: string): Promise { - const all = await this.repo.findAll(); - return all.filter((i) => i.state === "accepted" && i.solver === solver).length; - } - - /** - * Count the number of intents in "open" or "accepted" state for a user. - * - * Used by IntentsController.create() to enforce MAX_OPEN_INTENTS_PER_USER. - * The query is a simple filter over findByUser so it works identically - * against the in-memory adapter and — once the repo is swapped — can be - * replaced with an efficient Prisma COUNT query without touching the service - * interface (issue #1). - */ - async countOpenByUser(user: string): Promise { - const userIntents = await this.repo.findByUser(user); - return userIntents.filter( - (i) => i.state === "open" || i.state === "accepted", - ).length; - } - - /** - * Patch an intent without going through a lifecycle edge. - * - * Production callers only patch non-state fields (`quotedDstAmount`), which is - * why this stays a plain repository call. A `state` in the patch is an - * unconditional write that bypasses the guarded `*If*` methods, and therefore - * also bypasses the funnel counters, the audit trail and the shadow monitor — - * it is used by test setup only. It is logged so that a future production - * caller is caught in review rather than silently skewing the dashboards. - */ - async update(id: string, patch: Partial): Promise { - if (patch.state !== undefined) { - this.logger.warn( - `[state-machine] update(${id}) carries a state patch ("${patch.state}"); ` + - `this bypasses the guarded transitions and their observers`, - ); - } - return this.repo.update(id, patch); - } - - /** - * Atomically accept an intent only if it is currently "open" with a future - * deadline (issue #473). Delegates to the repository so both in-memory and - * Prisma adapters apply the conditional write atomically. - * - * The new deadline is set to now + fill window from governance params (or - * CHAIN_FILL_WINDOW_DEFAULTS[srcChain] as fallback) so solvers on - * slower-settling chains get a proportionally longer window and are not - * unfairly slashed for a deadline that was never realistic. - * Returns null when the intent is not found, not open, or past deadline. - */ - async acceptIfOpen(id: string, solver: string, now?: number): Promise { - const intent = await this.repo.findById(id); - if (!intent) return null; - const nowSec = now ?? Math.floor(Date.now() / 1000); - const fillWindow = - CHAIN_FILL_WINDOW_DEFAULTS[intent.srcChain] ?? DEFAULT_FILL_WINDOW_SECONDS; - const updated = await this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec); - if (updated !== null) this.countTransition("open", "accepted"); - if (this.beginShadowObservation()) { - this.observeAccept(updated ?? intent, solver, updated !== null); - } - return updated; - } - - /** Shadow hook for `accept` — reported whether or not the conditional write won. */ - private observeAccept(intent: Intent, solver: string, committed: boolean): void { - this.reportShadow( - "accept", - intent.intentId, - committed, - "accept_intent", - this.safeArgs(() => [ - nativeToScVal(intent.intentId, { type: "string" }), - new Address(solver).toScVal(), - nativeToScVal(intent.deadline, { type: "u64" }), - ]), - ); - } - - /** - * Atomically fill an intent only if it is currently "accepted" by the given - * solver with a future deadline (issue #473). - * Returns null when the intent is not found, not accepted, assigned to a - * different solver, or past the fill window (sweeper wins). - */ - async fillIfAccepted( - id: string, - solver: string, - patch: Omit, "state" | "solver">, - now?: number, - ): Promise { - const nowSec = now ?? Math.floor(Date.now() / 1000); - const updated = await this.repo.fillIfAccepted(id, solver, patch, nowSec); - if (updated !== null) this.countTransition("accepted", "filled"); - if (this.beginShadowObservation()) { - // Report from `patch` rather than re-reading: on a lost race the stored - // record belongs to whoever won, so its fill amount is not the amount - // this call was asked to settle. The submitted values are the ones the - // contract would have been handed if the off-chain guard had not - // pre-empted it. - this.observeFill(id, solver, patch.fillAmount, patch.txHash, updated !== null); - } - return updated; - } - - /** Shadow hook for `fill` — reported whether or not the conditional write won. */ - private observeFill( - intentId: string, - solver: string, - fillAmount: string | undefined, - txHash: string | undefined, - committed: boolean, - ): void { - this.reportShadow( - "fill", - intentId, - committed, - "fill_intent", - this.safeArgs(() => [ - nativeToScVal(intentId, { type: "string" }), - new Address(solver).toScVal(), - nativeToScVal(BigInt(fillAmount ?? "0"), { type: "i128" }), - nativeToScVal(txHash ?? "", { type: "string" }), - ]), - ); - } - - /** - * Atomically cancel an intent only if it is currently "open". - * Returns null when the intent is not found or is not in the "open" state - * (e.g. a concurrent accept() or sweeper expiry already transitioned it). - */ - async cancelIfOpen(id: string): Promise { - const updated = await this.repo.cancelIfOpen(id); - if (updated !== null) this.countTransition("open", "cancelled"); - if (this.beginShadowObservation()) { - const subject = updated ?? (await this.repo.findById(id)); - if (subject) { - this.reportShadow( - "cancel", - subject.intentId, - updated !== null, - "cancel_intent", - this.safeArgs(() => [ - nativeToScVal(subject.intentId, { type: "string" }), - new Address(subject.user).toScVal(), - ]), - ); - } - } - return updated; - } - - /** - * Atomically expire an intent only if it is currently "open". - * Used by the sweeper so a concurrent user cancel() or solver accept() - * always wins the race. - */ - async expireIfOpen(id: string): Promise { - const updated = await this.repo.expireIfOpen(id); - if (updated !== null) this.countTransition("open", "expired"); - if (this.beginShadowObservation()) { - const subject = updated ?? (await this.repo.findById(id)); - if (subject) { - this.reportShadow( - "expire", - subject.intentId, - updated !== null, - "expire_intent", - this.safeArgs(() => [ - nativeToScVal(subject.intentId, { type: "string" }), - nativeToScVal(subject.deadline, { type: "u64" }), - ]), - ); - } - } - return updated; - } - - /** - * Atomically slash an intent only if it is currently "accepted". - * Used by the sweeper so a concurrent solver fill() always wins the race. - */ - async slashIfAccepted( - id: string, - patch: { slashedAt: number; slashReason: string }, - ): Promise { - const updated = await this.repo.slashIfAccepted(id, patch); - if (updated !== null) this.countTransition("accepted", "slashed"); - if (this.beginShadowObservation()) { - const subject = updated ?? (await this.repo.findById(id)); - // An "accepted" intent always carries a solver. A record without one is - // corrupt, so skip the simulation rather than encoding a null address — - // the sweep loop already logs that case loudly. - const slashedSolver = subject?.solver; - if (subject && slashedSolver) { - this.reportShadow( - "slash", - subject.intentId, - updated !== null, - "slash_intent", - this.safeArgs(() => [ - nativeToScVal(subject.intentId, { type: "string" }), - new Address(slashedSolver).toScVal(), - nativeToScVal(patch.slashReason, { type: "string" }), - nativeToScVal(patch.slashedAt, { type: "u64" }), - ]), - ); - } - } - return updated; - } - - /** - * Issue #477 — extend an accepted intent's fill window, used by the sweeper - * while an emergency pause blocks fills so the solver is not slashed for a - * pause it did not cause. Returns null when the intent is no longer accepted - * or already has a later deadline. - */ - async extendDeadlineIfAccepted(id: string, newDeadline: number): Promise { - return this.repo.extendDeadlineIfAccepted(id, newDeadline); - } - - // --------------------------------------------------------------------------- - // Audit trail (issue #217 / #62) - // --------------------------------------------------------------------------- - - /** - * Append a new audit entry for the given intent. - * - * Writes to both the in-memory log (fast read path / restart fallback) and - * the persistent `intent_audit_log` table via PrismaService. - * - * Per issue #217: the DB write is non-blocking relative to the state - * transition — a write failure is logged loudly but never rolls back or - * blocks the caller. - */ - appendAuditEntry( - intentId: string, - toState: IntentState, - actor: string, - reason: string, - metadata?: Record, - ): void { - const entry: IntentAuditEntry = { - timestamp: new Date().toISOString(), - toState, - actor, - reason, - ...(metadata ? { metadata } : {}), - }; - - // 1. In-memory write (synchronous, always succeeds) - const entries = this.auditLog.get(intentId) ?? []; - entries.push(entry); - this.auditLog.set(intentId, entries); - - // 2. Persistent DB write (fire-and-forget, failures are logged loudly) - // NOTE: intentAuditLog is added to the Prisma client by the migration in - // prisma/migrations/20260828000002_intent_audit_log/migration.sql. - // The type assertion is needed until `npm run db:generate` runs in CI - // against the updated schema.prisma. - (this.prisma as unknown as { - intentAuditLog: { - create: (args: { - data: { - intentId: string; - toState: string; - actor: string; - reason: string; - metadata?: Record; - timestamp: Date; - }; - }) => Promise; - }; - }).intentAuditLog - .create({ - data: { - intentId, - toState, - actor, - reason, - metadata: metadata ?? undefined, - timestamp: new Date(entry.timestamp), - }, - }) - .catch((err: unknown) => { - this.logger.error( - `[audit] FAILED to persist audit entry for intent ${intentId} ` + - `(toState=${toState}, actor=${actor}): ${(err as Error).message}`, - (err as Error).stack, - ); - }); - } - - /** - * Return the full audit trail for a given intent, oldest-first. - * - * Reads from the in-memory log as the fast path. Once the in-memory store is - * replaced with a real DB (issue #36), this should read directly from the - * `intent_audit_log` table ordered by timestamp ASC. - * - * Returns an empty array if the intent has no recorded transitions. - */ - getAuditLog(intentId: string, limit?: number, offset?: number): IntentAuditEntry[] { - const entries = this.auditLog.get(intentId) ?? []; - if (limit === undefined && offset === undefined) return entries; - - const safeLimit = Math.min(limit ?? 20, 100); - const safeOffset = Math.max(0, offset ?? 0); - return entries.slice(safeOffset, safeOffset + safeLimit); - } -} diff --git a/src/intents/rfq.types.ts b/src/intents/rfq.types.ts new file mode 100644 index 00000000..2941252d --- /dev/null +++ b/src/intents/rfq.types.ts @@ -0,0 +1,35 @@ +import { SupportedChain } from "./intents.types"; + +export interface RfqQuoteRequest { + requestId: string; + srcChain: SupportedChain; + srcTokenSymbol: string; + srcAmount: string; + dstTokenSymbol: string; + srcTokenAddress?: string; + dstTokenContract?: string; + deadline: number; +} + +export interface RfqResponseSignaturePayload extends RfqQuoteRequest { + solver: string; + dstAmount: string; + fee: string; + expiresAt: number; +} + +export interface RfqQuoteResponse { + type: "rfq_response"; + requestId: string; + dstAmount: string; + fee: string; + expiresAt: number; + signature: string; +} + +export interface VerifiedRfqQuote { + solver: string; + dstAmount: string; + fee: string; + expiresAt: number; +} \ No newline at end of file diff --git a/src/soroban/fill-verifier.service.ts b/src/soroban/fill-verifier.service.ts index e69de29b..716469c3 100644 --- a/src/soroban/fill-verifier.service.ts +++ b/src/soroban/fill-verifier.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Asset } from "@stellar/stellar-sdk"; +import { EgressPurpose, HttpEgressService } from "../common/http-egress"; +import { AppConfig, NETWORK_PASSPHRASES } from "../config/configuration"; +import { Intent } from "../intents/intents.types"; + +/** A structured independent verdict for a submitted Stellar fill transaction. */ +export type FillVerificationVerdict = + | { status: "verified"; deliveredAmount: string; operation: string } + | { status: "pending"; reason: "not_indexed" | "horizon_unavailable" } + | { status: "rejected"; reason: string }; + +type HorizonTransaction = { + successful?: boolean; + memo_type?: string; + memo?: string; + _links?: { operations?: { href?: string } }; +}; + +/** Independently checks Horizon's indexed Stellar transaction and payment operations. */ +@Injectable() +export class FillVerifierService { + private readonly egress: HttpEgressService; + private readonly horizonBase: string; + + constructor(private readonly config: ConfigService) { + const horizonUrl = config.get("stellar.horizonUrl", { infer: true }); + this.horizonBase = horizonUrl.replace(/\/$/, ""); + this.egress = new HttpEgressService({ + timeoutMs: 10_000, + maxRedirects: 0, + maxBodySizeBytes: 1_048_576, + allowlist: [new URL(horizonUrl).hostname], + blockPrivateRanges: false, + }); + } + + /** + * Verify the transaction against the persisted intent. Unknown/indexing errors + * remain retryable; malformed or mismatched transactions are definitive. + */ + async verify(txHash: string, intent: Intent): Promise { + const base = this.horizonBase; + let transaction: HorizonTransaction; + try { + const response = await this.egress.fetch(`${base}/transactions/${encodeURIComponent(txHash)}`, { + purpose: EgressPurpose.HORIZON, + }); + if (response.statusCode === 404) return { status: "pending", reason: "not_indexed" }; + if (response.statusCode < 200 || response.statusCode >= 300) return { status: "pending", reason: "horizon_unavailable" }; + transaction = JSON.parse(response.body) as HorizonTransaction; + } catch { + return { status: "pending", reason: "horizon_unavailable" }; + } + + if (transaction.successful !== true) return { status: "rejected", reason: "transaction_failed" }; + if (transaction.memo_type !== "text" || transaction.memo !== intent.intentId) { + return { status: "rejected", reason: "intent_memo_mismatch" }; + } + if (!transaction._links?.operations?.href) return { status: "rejected", reason: "operations_missing" }; + try { + const response = await this.egress.fetch(`${base}/transactions/${encodeURIComponent(txHash)}/operations?limit=200&order=asc`, { + purpose: EgressPurpose.HORIZON, + }); + if (response.statusCode < 200 || response.statusCode >= 300) return { status: "pending", reason: "horizon_unavailable" }; + const body = JSON.parse(response.body) as { _embedded?: { records?: Array> } }; + const operations = body._embedded?.records ?? []; + for (const operation of operations) { + const type = operation.type as string; + if (!["payment", "path_payment_strict_send", "path_payment_strict_receive"].includes(type)) continue; + if (operation.source_account && operation.source_account !== intent.solver) continue; + const destination = String(operation.to ?? ""); + const network = this.config.get("stellar.network", { infer: true }); + let assetContractId: string; + try { + assetContractId = operation.asset_type === "native" + ? Asset.native().contractId(NETWORK_PASSPHRASES[network]) + : new Asset(String(operation.asset_code ?? ""), String(operation.asset_issuer ?? "")) + .contractId(NETWORK_PASSPHRASES[network]); + } catch { + continue; + } + if (destination !== intent.user) continue; + // Horizon exposes classic assets by code/issuer. Soroban contract IDs + // require Soroban RPC event verification, and are never credited here. + if (assetContractId !== intent.dstToken.contract) continue; + const raw = type.startsWith("path_payment_") ? operation.destination_amount : operation.amount; + if (typeof raw !== "string" || !/^\d+(\.\d+)?$/.test(raw)) continue; + const delivered = decimalToBaseUnits(raw, intent.dstToken.decimals); + if (BigInt(delivered) < BigInt(intent.minDstAmount)) { + return { status: "rejected", reason: "insufficient_delivered_amount" }; + } + return { status: "verified", deliveredAmount: delivered, operation: type }; + } + return { status: "rejected", reason: "matching_payment_missing" }; + } catch { + return { status: "pending", reason: "horizon_unavailable" }; + } + } +} + +function decimalToBaseUnits(value: string, decimals: number): string { + const [whole, fraction = ""] = value.split("."); + if (fraction.length > decimals) throw new Error("Horizon amount precision exceeds token decimals"); + return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt((fraction + "0".repeat(decimals)).slice(0, decimals) || "0")).toString(); +} diff --git a/src/soroban/redaction.ts b/src/soroban/redaction.ts index 58203d04..e69de29b 100644 --- a/src/soroban/redaction.ts +++ b/src/soroban/redaction.ts @@ -1,32 +0,0 @@ -const SENSITIVE_KEY_PATTERNS = [ - /S[A-Z2-7]{55}/g, - /secretKey\s*[:=]\s*["']?\S+/gi, - /privateKey\s*[:=]\s*["']?\S+/gi, -]; - -/** - * Scan a serialized error/log payload for raw Stellar secret-key material. - * Returns the first few suspicious matches so tests can assert that logs and - * thrown errors never expose the hot-wallet seed or similar credentials. - */ -export function findSensitiveKeyMaterial(value: unknown): string[] { - const text = typeof value === "string" ? value : JSON.stringify(value ?? ""); - const hits = new Set(); - - for (const pattern of SENSITIVE_KEY_PATTERNS) { - const matches = text.match(pattern); - if (!matches) continue; - for (const match of matches) { - hits.add(match); - } - } - - return [...hits].slice(0, 10); -} - -export function assertNoSensitiveKeyMaterial(value: unknown, context = "serialized payload"): void { - const leaked = findSensitiveKeyMaterial(value); - if (leaked.length > 0) { - throw new Error(`${context} contains sensitive key material: ${leaked.join(", ")}`); - } -} diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index d6e2200a..e69de29b 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -1,169 +0,0 @@ -import { inspect } from "node:util"; -import { ConfigService } from "@nestjs/config"; -import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; -import { AppConfig } from "../config/configuration"; -import { SignerService } from "./signer.service"; -import { SorobanService } from "./soroban.service"; -import { ISigner } from "./signers/signer.interface"; -import { LocalKeypairSigner } from "./signers/local-keypair.signer"; -import { findSensitiveKeyMaterial } from "./redaction"; - -/** - * Builds a local-keypair signing backend over a stubbed config, i.e. the same - * ISigner the SIGNER_TOKEN provider hands to SignerService in the app. - */ -function signerWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet"): ISigner { - const values: Record = { - "stellar.signingKey": signerSecretKey, - "stellar.network": network, - }; - const configService = { get: (path: string) => values[path] } as ConfigService; - return new LocalKeypairSigner(configService); -} - -function fakeSorobanService(startingSequence = "100") { - return { - getAccount: jest.fn().mockImplementation(async (publicKey: string) => new Account(publicKey, startingSequence)), - } as unknown as jest.Mocked; -} - -describe("SignerService", () => { - it("reports unconfigured when no secret is set", () => { - const service = new SignerService(signerWith(""), fakeSorobanService()); - expect(service.isConfigured()).toBe(false); - }); - - it("throws a clear, secret-free error when signing without a configured key", () => { - const service = new SignerService(signerWith(""), fakeSorobanService()); - expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNING_KEY/); - }); - - it("derives the public key from the configured secret", () => { - const keypair = Keypair.random(); - const service = new SignerService(signerWith(keypair.secret()), fakeSorobanService()); - - expect(service.isConfigured()).toBe(true); - expect(service.getPublicKey()).toBe(keypair.publicKey()); - }); - - it("maps network config to the right passphrase", () => { - const soroban = fakeSorobanService(); - expect(new SignerService(signerWith("", "testnet"), soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); - expect(new SignerService(signerWith("", "futurenet"), soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); - expect(new SignerService(signerWith("", "mainnet"), soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC); - }); - - it("signs a transaction with the configured key", async () => { - const keypair = Keypair.random(); - const service = new SignerService(signerWith(keypair.secret()), fakeSorobanService()); - - const account = new Account(keypair.publicKey(), "1"); - const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET }) - .addOperation(Operation.bumpSequence({ bumpTo: "2" })) - .setTimeout(30) - .build(); - - expect(tx.signatures).toHaveLength(0); - const signed = await service.sign(tx); - expect(signed.signatures).toHaveLength(1); - }); - - it("never includes the raw secret in string/JSON/inspect representations", () => { - const keypair = Keypair.random(); - const service = new SignerService(signerWith(keypair.secret()), fakeSorobanService()); - - const secret = keypair.secret(); - expect(String(service)).not.toContain(secret); - expect(JSON.stringify(service)).not.toContain(secret); - expect(inspect(service)).not.toContain(secret); - expect(findSensitiveKeyMaterial(service)).toEqual([]); - }); - - it("exposes no raw Stellar secret in serialized error payloads", () => { - const keypair = Keypair.random(); - const secret = keypair.secret(); - const payload = { - error: "transaction simulation failed", - signer: { secretKey: secret, publicKey: keypair.publicKey() }, - }; - - expect(findSensitiveKeyMaterial(payload)).toContain(secret); - expect(findSensitiveKeyMaterial({ error: "ok" })).toEqual([]); - }); - - describe("withNextSequence", () => { - it("fetches the starting sequence once and increments it locally", async () => { - const keypair = Keypair.random(); - const soroban = fakeSorobanService("100"); - const service = new SignerService(signerWith(keypair.secret()), soroban); - - const first = await service.withNextSequence(async (sequence) => sequence); - const second = await service.withNextSequence(async (sequence) => sequence); - const third = await service.withNextSequence(async (sequence) => sequence); - - expect([first, second, third]).toEqual(["101", "102", "103"]); - expect(soroban.getAccount).toHaveBeenCalledTimes(1); - }); - - it("hands out a distinct, gap-free sequence to every concurrent caller", async () => { - const keypair = Keypair.random(); - const soroban = fakeSorobanService("0"); - const service = new SignerService(signerWith(keypair.secret()), soroban); - - const results = await Promise.all( - Array.from({ length: 20 }, () => service.withNextSequence(async (sequence) => sequence)), - ); - - const numeric = results.map(Number).sort((a, b) => a - b); - expect(new Set(numeric).size).toBe(20); // no two callers got the same sequence - expect(numeric).toEqual(Array.from({ length: 20 }, (_, i) => i + 1)); // 1..20, no gaps - }); - - it("runs callers strictly one at a time, in call order", async () => { - const keypair = Keypair.random(); - const service = new SignerService(signerWith(keypair.secret()), fakeSorobanService("0")); - const order: number[] = []; - - const slow = service.withNextSequence(async () => { - await new Promise((resolve) => setTimeout(resolve, 30)); - order.push(1); - }); - const fast = service.withNextSequence(async () => { - order.push(2); - }); - - await Promise.all([slow, fast]); - expect(order).toEqual([1, 2]); // fast waited for slow despite finishing faster on its own - }); - - it("drops the cached sequence after a failure so the next call re-syncs from the network", async () => { - const keypair = Keypair.random(); - const soroban = fakeSorobanService("100"); - const service = new SignerService(signerWith(keypair.secret()), soroban); - - await expect( - service.withNextSequence(async () => { - throw new Error("submission failed"); - }), - ).rejects.toThrow("submission failed"); - - const next = await service.withNextSequence(async (sequence) => sequence); - expect(next).toBe("101"); - expect(soroban.getAccount).toHaveBeenCalledTimes(2); // re-fetched after the failure - }); - - it("does not let a failed caller block callers queued behind it", async () => { - const keypair = Keypair.random(); - const service = new SignerService(signerWith(keypair.secret()), fakeSorobanService("0")); - - const failing = service.withNextSequence(async () => { - throw new Error("boom"); - }); - const following = service.withNextSequence(async (sequence) => sequence); - - await expect(failing).rejects.toThrow("boom"); - // cache was dropped after the failure, so this re-syncs from the network (still "0") and gets "1" - await expect(following).resolves.toBe("1"); - }); - }); -}); diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index f3e2459e..e69de29b 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -1,158 +0,0 @@ -import { ConfigService } from "@nestjs/config"; -import { SolverRegistryService } from "./solver-registry.service"; -import { AppConfig } from "../config/configuration"; - -function makeConfigService( - overrides: Partial = {}, - appOverrides: Partial> = {}, -) { - const stellar: AppConfig["stellar"] = { - network: "testnet", - sorobanRpcUrl: "https://soroban-testnet.stellar.org", - horizonUrl: "https://horizon-testnet.stellar.org", - settlementContractId: "", - solverRegistryContractId: "", - signerSecretKey: "", - signingKey: "", - feePercentile: "p50", - ...overrides, - }; - const config: AppConfig = { - nodeEnv: "test", - port: 4000, - databaseUrl: "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", - stellar, - treasury: { address: "" }, - onchainIntentsEnabled: false, - intentRetentionDays: 30, - intentRetentionSweepMs: 60000, - // Default to dry-run true for tests (safe default) - onchainDryRun: appOverrides.onchainDryRun ?? true, - corsOrigin: "*", - wsMaxConnections: 1000, - wsBackplane: "memory", - redisUrl: "redis://localhost:6379", - // Resource-exhaustion limits (issue #476) — test defaults - jsonMaxDepth: 10, - wsMaxFilterChains: 20, - wsMaxSubscriptions: 10, - dbQueryTimeoutMs: 5000, - dbBatchQueryTimeoutMs: 10000, - dbStatsQueryTimeoutMs: 15000, - // Emergency kill-switch (issue #477) — no operator token in unit tests, so - // the control plane stays disabled. - killswitch: { - operatorToken: "", - redisUrl: "", - pollMs: 2000, - }, - shadow: { - enabled: false, - sampleRate: 1, - queueMax: 256, - concurrency: 4, - sourceAccount: "", - }, - governance: { - paramsContractId: "", - paramsPollIntervalMs: 30_000, - }, - leaderElection: { - enabled: false, - heartbeatMs: 5000, - }, - processRole: "all", - jobs: { driver: "memory", shutdownTimeoutMs: 25000 }, - flags: { pubsub: "memory", refreshMs: 30000, overrides: "" }, - adminApiKeys: "", - guardianContractId: "", - canaryAddresses: [], - datasets: { - enabled: false, - anonymize: true, - salt: "", - saltRotationHours: 24, - saltRetentionWindows: 2, - publicBucket: "vortex-public-datasets", - storageKind: "local", - localDir: ".datasets", - }, - }; - return { - get: (key: string) => { - if (key === "onchainDryRun") return config.onchainDryRun; - const parts = key.split("."); - return (config as unknown as Record)[parts[0]] && parts[0] === "stellar" - ? (stellar as unknown as Record)[parts[1]] - : undefined; - }, - } as unknown as ConfigService; -} - -describe("SolverRegistryService", () => { - it("is not configured when the contract id and signing key are both empty (default)", () => { - const service = new SolverRegistryService(makeConfigService()); - expect(service.isConfigured).toBe(false); - }); - - it("is not configured when only the contract id is set", () => { - const service = new SolverRegistryService( - makeConfigService({ solverRegistryContractId: "CABCDEF" }), - ); - expect(service.isConfigured).toBe(false); - }); - - it("no-ops without contacting the network when unconfigured (dry-run=true)", async () => { - const service = new SolverRegistryService(makeConfigService()); - const result = await service.slashSolver({ - solverAddress: "GSOLVER", - intentId: "intent-1", - reason: "missed deadline", - }); - - expect(result.submitted).toBe(false); - expect(result.simulated).toBe(false); - // In dry-run mode, dryRun flag is true - expect(result.dryRun).toBe(true); - }); -}); - -// ── #260: dry-run flag behaviour ───────────────────────────────────────────── - -describe("SolverRegistryService — dry-run flag (#260)", () => { - it("returns dryRun:true without simulating when ONCHAIN_DRY_RUN=true", async () => { - const service = new SolverRegistryService( - makeConfigService( - { solverRegistryContractId: "CTEST123", signingKey: "S" + "A".repeat(55) }, - { onchainDryRun: true }, - ), - ); - - const result = await service.slashSolver({ - solverAddress: "GSOLVER", - intentId: "intent-1", - reason: "missed deadline", - }); - - expect(result.submitted).toBe(false); - expect(result.dryRun).toBe(true); - expect(result.detail).toMatch(/ONCHAIN_DRY_RUN=true/); - }); - - it("returns dryRun:false when ONCHAIN_DRY_RUN=false and service is not fully configured", async () => { - // With dryRun=false but contract not configured → falls through to no-op - const service = new SolverRegistryService( - makeConfigService({}, { onchainDryRun: false }), - ); - - const result = await service.slashSolver({ - solverAddress: "GSOLVER", - intentId: "intent-1", - reason: "missed deadline", - }); - - expect(result.submitted).toBe(false); - expect(result.dryRun).toBe(false); - expect(result.detail).toMatch(/not configured/i); - }); -}); diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index 6817c4cb..e69de29b 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -1,96 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { SorobanRpc, Transaction } from "@stellar/stellar-sdk"; -import { AppConfig } from "../config/configuration"; - -@Injectable() -export class SorobanService { - private readonly server: SorobanRpc.Server; - private readonly rpcUrl: string; - - constructor(configService: ConfigService) { - const rpcUrl = configService.get("stellar.sorobanRpcUrl", { infer: true }); - this.rpcUrl = rpcUrl; - this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }); - } - - getHealth() { - return this.server.getHealth(); - } - - getLatestLedger() { - return this.server.getLatestLedger(); - } - - getNetwork() { - return this.server.getNetwork(); - } - - getAccount(publicKey: string) { - return this.server.getAccount(publicKey); - } - - /** - * Fetch a ledger header by sequence number. - * - * Used by the event-ingestion loop to date the newest event it has seen: the - * `closeTime` here is what makes `vortex_event_ingestion_lag_seconds` a real - * measurement rather than a guess. - * - * @stellar/stellar-sdk 12 has no typed wrapper for the RPC `getLedgers` - * method, so the JSON-RPC call is issued directly. The result is returned in - * the `{ header: { closeTime } }` shape the ingestion loop reads. - */ - async getLedger(sequence: number): Promise<{ header?: { closeTime?: string } }> { - const response = await fetch(this.rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: `get-ledgers-${sequence}`, - method: "getLedgers", - params: { startLedger: sequence, endLedger: sequence }, - }), - }); - if (!response.ok) { - throw new Error(`getLedgers HTTP ${response.status} for ledger ${sequence}`); - } - const body = (await response.json()) as { - result?: { ledgers?: Array<{ ledgerCloseTime?: string }> }; - error?: { message?: string }; - }; - if (body.error) { - throw new Error(`getLedgers RPC error for ledger ${sequence}: ${body.error.message ?? "unknown"}`); - } - const ledger = body.result?.ledgers?.[0]; - return ledger ? { header: { closeTime: ledger.ledgerCloseTime } } : {}; - } - - getEvents(request: SorobanRpc.Server.GetEventsRequest) { - return this.server.getEvents(request); - } - - getFeeStats(): Promise { - return this.server.getFeeStats(); - } - - simulateTransaction( - transaction: Transaction, - ): Promise { - return this.server.simulateTransaction(transaction); - } - - prepareTransaction( - transaction: Transaction, - ): Promise { - return this.server.prepareTransaction(transaction) as Promise; - } - - submitTransaction(transaction: Transaction): Promise { - return this.server.sendTransaction(transaction); - } - - getTransaction(hash: string): Promise { - return this.server.getTransaction(hash); - } -} diff --git a/src/treasury/treasury.service.ts b/src/treasury/treasury.service.ts index 01266ada..e69de29b 100644 --- a/src/treasury/treasury.service.ts +++ b/src/treasury/treasury.service.ts @@ -1,535 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Cron, CronExpression } from "@nestjs/schedule"; -import { PrismaService } from "../prisma/prisma.service"; -import { SorobanService } from "../soroban/soroban.service"; -import * as StellarSdk from "@stellar/stellar-sdk"; -import { - AssetBalance, - ExpectedBalance, - ReconciliationResult, - ReconciliationSummary, - ReconciliationDetailResponse, - FeeLedgerEntry, - SlashLedgerEntry, - RefundLedgerEntry, -} from "./treasury.types"; -import { AppConfig } from "../config/configuration"; - -/** - * TreasuryService - * - * Aggregates fee-ledger accruals, slash proceeds, and refunds, - * and reconciles them daily against actual on-chain treasury balances. - */ -@Injectable() -export class TreasuryService { - private readonly logger = new Logger(TreasuryService.name); - private readonly horizonServer: StellarSdk.Horizon.Server; - private readonly treasuryAddress: string; - private readonly toleranceThresholds: Map; - - constructor( - private readonly prisma: PrismaService, - private readonly soroban: SorobanService, - private readonly configService: ConfigService, - ) { - const horizonUrl = this.configService.get("stellar.horizonUrl", { infer: true }); - this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); - - this.treasuryAddress = this.configService.get("treasury.address", { infer: true }); - - // Default tolerance thresholds per asset (in base units) - // Could be moved to config/database - this.toleranceThresholds = new Map([ - ["native", 10000000n], // 1 XLM (7 decimals) - ["USDC", 1000000n], // 1 USDC (6 decimals) - ]); - } - - /** - * Record a fee accrual in the ledger - */ - async recordFee(entry: FeeLedgerEntry): Promise { - await this.prisma.feeLedger.create({ - data: { - intentId: entry.intentId, - asset: entry.asset, - amount: entry.amount, - accrualAt: entry.accrualAt, - txHash: entry.txHash, - }, - }); - - this.logger.log(`Recorded fee: ${entry.amount} ${entry.asset} for intent ${entry.intentId}`); - } - - /** - * Record a slash in the ledger - */ - async recordSlash(entry: SlashLedgerEntry): Promise { - await this.prisma.slashLedger.create({ - data: { - solverAddress: entry.solverAddress, - asset: entry.asset, - amount: entry.amount, - slashedAt: entry.slashedAt, - reason: entry.reason, - txHash: entry.txHash, - }, - }); - - this.logger.log(`Recorded slash: ${entry.amount} ${entry.asset} from solver ${entry.solverAddress}`); - } - - /** - * Record a refund in the ledger - */ - async recordRefund(entry: RefundLedgerEntry): Promise { - await this.prisma.refundLedger.create({ - data: { - intentId: entry.intentId, - userAddress: entry.userAddress, - asset: entry.asset, - amount: entry.amount, - issuedAt: entry.issuedAt, - reason: entry.reason, - txHash: entry.txHash, - }, - }); - - this.logger.log(`Recorded refund: ${entry.amount} ${entry.asset} to user ${entry.userAddress}`); - } - - /** - * Calculate expected treasury balance from ledgers - */ - async calculateExpectedBalance(asset: string, untilDate?: Date): Promise { - const until = untilDate || new Date(); - - // Aggregate fees - const fees = await this.prisma.feeLedger.findMany({ - where: { - asset, - accrualAt: { lte: until }, - }, - }); - const totalFees = fees.reduce((sum, f) => sum + BigInt(f.amount), 0n); - - // Aggregate slashes - const slashes = await this.prisma.slashLedger.findMany({ - where: { - asset, - slashedAt: { lte: until }, - }, - }); - const totalSlashes = slashes.reduce((sum, s) => sum + BigInt(s.amount), 0n); - - // Aggregate refunds - const refunds = await this.prisma.refundLedger.findMany({ - where: { - asset, - issuedAt: { lte: until }, - }, - }); - const totalRefunds = refunds.reduce((sum, r) => sum + BigInt(r.amount), 0n); - - const netExpected = totalFees + totalSlashes - totalRefunds; - - return { - asset, - totalFees: totalFees.toString(), - totalSlashes: totalSlashes.toString(), - totalRefunds: totalRefunds.toString(), - netExpected: netExpected.toString(), - }; - } - - /** - * Fetch actual on-chain balance for treasury account - */ - async fetchActualBalance(asset: string): Promise { - try { - const account = await this.horizonServer.loadAccount(this.treasuryAddress); - - // Handle native XLM - if (asset === "native") { - const balance = account.balances.find((b) => b.asset_type === "native"); - return { - asset: "native", - balance: balance ? this.parseBalance(balance.balance) : "0", - }; - } - - // Handle issued assets (traditional Stellar assets) - const [code, issuer] = asset.split(":"); - if (issuer) { - const balance = account.balances.find( - (b) => - b.asset_type !== "native" && - "asset_code" in b && - "asset_issuer" in b && - b.asset_code === code && - b.asset_issuer === issuer, - ); - return { - asset, - balance: balance ? this.parseBalance(balance.balance) : "0", - }; - } - - // Handle Soroban tokens (SAC balances) - // This would require calling a Soroban contract method - // For now, return placeholder - implement based on your contract structure - this.logger.warn(`Soroban asset balance fetch not yet implemented for ${asset}`); - return { - asset, - balance: "0", - contract: asset, - }; - } catch (error) { - this.logger.error(`Failed to fetch balance for ${asset}:`, error); - throw error; - } - } - - /** - * Parse Horizon balance string to base units (stroops) - */ - private parseBalance(balance: string): string { - // Horizon returns balances as decimal strings like "100.0000000" - // Convert to stroops (1 XLM = 10^7 stroops) - const [whole, decimal = ""] = balance.split("."); - const paddedDecimal = decimal.padEnd(7, "0"); - return (BigInt(whole) * 10000000n + BigInt(paddedDecimal)).toString(); - } - - /** - * Perform reconciliation for a single asset - */ - async reconcileAsset( - asset: string, - date: Date = new Date(), - ): Promise { - const snapshotDate = date.toISOString().split("T")[0]; - - this.logger.log(`Reconciling asset ${asset} for date ${snapshotDate}`); - - // Calculate expected balance from ledgers - const expected = await this.calculateExpectedBalance(asset, date); - - // Fetch actual on-chain balance - const actual = await this.fetchActualBalance(asset); - - const expectedBigInt = BigInt(expected.netExpected); - const actualBigInt = BigInt(actual.balance); - const discrepancy = actualBigInt - expectedBigInt; - const absDiscrepancy = discrepancy < 0n ? -discrepancy : discrepancy; - - const tolerance = this.toleranceThresholds.get(asset) || 0n; - const hasUnexplainedDiscrepancy = absDiscrepancy > tolerance; - - // Calculate percentage - const discrepancyPercentage = expectedBigInt > 0n - ? Number((discrepancy * 10000n) / expectedBigInt) / 100 - : 0; - - // Generate explanation - const explanation = this.generateExplanation( - discrepancy, - hasUnexplainedDiscrepancy, - asset, - ); - - const result: ReconciliationResult = { - snapshotDate, - asset, - expectedBalance: expected.netExpected, - actualBalance: actual.balance, - discrepancy: discrepancy.toString(), - discrepancyPercentage, - toleranceThreshold: tolerance.toString(), - hasUnexplainedDiscrepancy, - explanation, - breakdown: { - fees: expected.totalFees, - slashes: expected.totalSlashes, - refunds: expected.totalRefunds, - }, - }; - - // Save snapshot to database - await this.prisma.treasurySnapshot.upsert({ - where: { - snapshot_date_asset_unique: { - snapshotDate, - asset, - }, - }, - create: { - snapshotDate, - asset, - expectedBalance: expected.netExpected, - actualBalance: actual.balance, - discrepancy: discrepancy.toString(), - toleranceThreshold: tolerance.toString(), - hasUnexplainedDiscrepancy, - explanation, - breakdown: result.breakdown, - }, - update: { - expectedBalance: expected.netExpected, - actualBalance: actual.balance, - discrepancy: discrepancy.toString(), - hasUnexplainedDiscrepancy, - explanation, - breakdown: result.breakdown, - }, - }); - - // Alert on unexplained discrepancies - if (hasUnexplainedDiscrepancy) { - await this.alertDiscrepancy(result); - } - - return result; - } - - /** - * Generate human-readable explanation for discrepancies - */ - private generateExplanation( - discrepancy: bigint, - hasUnexplainedDiscrepancy: boolean, - asset: string, - ): string | null { - if (discrepancy === 0n) { - return "Balances match exactly."; - } - - if (!hasUnexplainedDiscrepancy) { - return `Discrepancy within tolerance threshold. Likely due to in-flight settlements or pending transactions.`; - } - - const direction = discrepancy > 0n ? "higher" : "lower"; - return `Treasury balance is ${direction} than expected by ${discrepancy.toString()} base units. This exceeds the tolerance threshold and requires investigation.`; - } - - /** - * Perform daily reconciliation for all tracked assets - */ - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async performDailyReconciliation(): Promise { - this.logger.log("Starting daily treasury reconciliation"); - - try { - // Get all unique assets from ledgers - const assetsFromFees = await this.prisma.feeLedger.findMany({ - select: { asset: true }, - distinct: ["asset"], - }); - - const assetsFromSlashes = await this.prisma.slashLedger.findMany({ - select: { asset: true }, - distinct: ["asset"], - }); - - const allAssets = new Set([ - ...assetsFromFees.map((f) => f.asset), - ...assetsFromSlashes.map((s) => s.asset), - ]); - - const results: ReconciliationResult[] = []; - - for (const asset of allAssets) { - try { - const result = await this.reconcileAsset(asset); - results.push(result); - } catch (error) { - this.logger.error(`Failed to reconcile asset ${asset}:`, error); - } - } - - const withDiscrepancies = results.filter((r) => r.hasUnexplainedDiscrepancy); - - this.logger.log( - `Daily reconciliation complete. ${results.length} assets checked, ` + - `${withDiscrepancies.length} with unexplained discrepancies.`, - ); - } catch (error) { - this.logger.error("Daily reconciliation failed:", error); - throw error; - } - } - - /** - * Get reconciliation summary for a specific date - */ - async getReconciliationSummary(date?: string): Promise { - const snapshotDate = date || new Date().toISOString().split("T")[0]; - - const snapshots = await this.prisma.treasurySnapshot.findMany({ - where: { snapshotDate }, - orderBy: { asset: "asc" }, - }); - - const assets: ReconciliationResult[] = snapshots.map((s) => ({ - snapshotDate: s.snapshotDate, - asset: s.asset, - expectedBalance: s.expectedBalance, - actualBalance: s.actualBalance, - discrepancy: s.discrepancy, - discrepancyPercentage: this.calculatePercentage( - BigInt(s.discrepancy), - BigInt(s.expectedBalance), - ), - toleranceThreshold: s.toleranceThreshold, - hasUnexplainedDiscrepancy: s.hasUnexplainedDiscrepancy, - explanation: s.explanation, - breakdown: s.breakdown as any, - })); - - return { - date: snapshotDate, - assets, - totalDiscrepancies: assets.filter((a) => BigInt(a.discrepancy) !== 0n).length, - assetsWithUnexplainedDiscrepancies: assets.filter( - (a) => a.hasUnexplainedDiscrepancy, - ).length, - lastReconciliationAt: snapshots[0]?.createdAt.toISOString() || new Date().toISOString(), - }; - } - - /** - * Get detailed reconciliation for a specific asset - */ - async getReconciliationDetail( - asset: string, - date?: string, - ): Promise { - const snapshotDate = date || new Date().toISOString().split("T")[0]; - - const snapshot = await this.prisma.treasurySnapshot.findUnique({ - where: { - snapshot_date_asset_unique: { - snapshotDate, - asset, - }, - }, - }); - - if (!snapshot) { - throw new Error(`No reconciliation found for asset ${asset} on ${snapshotDate}`); - } - - // Fetch recent transactions (last 100 of each type) - const [fees, slashes, refunds] = await Promise.all([ - this.prisma.feeLedger.findMany({ - where: { asset }, - orderBy: { accrualAt: "desc" }, - take: 100, - }), - this.prisma.slashLedger.findMany({ - where: { asset }, - orderBy: { slashedAt: "desc" }, - take: 100, - }), - this.prisma.refundLedger.findMany({ - where: { asset }, - orderBy: { issuedAt: "desc" }, - take: 100, - }), - ]); - - const recentTransactions = [ - ...fees.map((f) => ({ - type: "fee" as const, - amount: f.amount, - timestamp: f.accrualAt.toISOString(), - reference: f.intentId, - })), - ...slashes.map((s) => ({ - type: "slash" as const, - amount: s.amount, - timestamp: s.slashedAt.toISOString(), - reference: s.solverAddress, - })), - ...refunds.map((r) => ({ - type: "refund" as const, - amount: r.amount, - timestamp: r.issuedAt.toISOString(), - reference: r.intentId, - })), - ].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); - - return { - snapshotDate: snapshot.snapshotDate, - asset: snapshot.asset, - expectedBalance: snapshot.expectedBalance, - actualBalance: snapshot.actualBalance, - discrepancy: snapshot.discrepancy, - discrepancyPercentage: this.calculatePercentage( - BigInt(snapshot.discrepancy), - BigInt(snapshot.expectedBalance), - ), - toleranceThreshold: snapshot.toleranceThreshold, - hasUnexplainedDiscrepancy: snapshot.hasUnexplainedDiscrepancy, - explanation: snapshot.explanation, - breakdown: snapshot.breakdown as any, - recentTransactions, - }; - } - - /** - * Calculate percentage from bigints - */ - private calculatePercentage(discrepancy: bigint, expected: bigint): number { - if (expected === 0n) return 0; - return Number((discrepancy * 10000n) / expected) / 100; - } - - /** - * Alert on unexplained discrepancies - */ - private async alertDiscrepancy(result: ReconciliationResult): Promise { - const severity = this.getSeverity(result); - - this.logger.warn( - `[${severity.toUpperCase()}] Treasury discrepancy detected for ${result.asset}: ` + - `${result.discrepancy} base units (${result.discrepancyPercentage.toFixed(2)}%)`, - ); - - // TODO: Integrate with alerting system (PagerDuty, Slack, etc.) - // For now, just log the alert - } - - /** - * Determine severity of discrepancy - */ - private getSeverity(result: ReconciliationResult): "warning" | "critical" { - const absPercentage = Math.abs(result.discrepancyPercentage); - - // Critical if discrepancy > 5% - if (absPercentage > 5) { - return "critical"; - } - - return "warning"; - } - - /** - * Manual reconciliation trigger (admin use) - */ - async triggerReconciliation(asset?: string): Promise { - if (asset) { - const result = await this.reconcileAsset(asset); - return [result]; - } - - // Reconcile all assets - await this.performDailyReconciliation(); - - const summary = await this.getReconciliationSummary(); - return summary.assets; - } -} diff --git a/test/chaos/runner.ts b/test/chaos/runner.ts index ec2be3a7..4f7d96d1 100644 --- a/test/chaos/runner.ts +++ b/test/chaos/runner.ts @@ -36,6 +36,7 @@ const results: ScenarioResult[] = []; // ── Toxiproxy helpers ──────────────────────────────────────────────────────── async function addToxic(proxy: string, toxic: ToxicConfig, name: string): Promise { + // eslint-disable-next-line no-restricted-syntax -- standalone chaos runner targets a local Toxiproxy endpoint const res = await fetch(`${TOXIPROXY}/proxies/${proxy}/toxics`, { method: "POST", headers: { "content-type": "application/json" }, @@ -54,6 +55,7 @@ async function addToxic(proxy: string, toxic: ToxicConfig, name: string): Promis } async function removeToxic(proxy: string, name: string): Promise { + // eslint-disable-next-line no-restricted-syntax -- standalone chaos runner targets a local Toxiproxy endpoint const res = await fetch(`${TOXIPROXY}/proxies/${proxy}/toxics/${name}`, { method: "DELETE", }); @@ -67,6 +69,7 @@ async function removeToxic(proxy: string, name: string): Promise { /** Returns the HTTP status of GET /health/ready. */ async function healthStatus(): Promise { try { + // eslint-disable-next-line no-restricted-syntax -- standalone chaos runner targets its configured test service const res = await fetch(`${BASE_URL}/health/ready`, { signal: AbortSignal.timeout(5_000) }); return res.status; } catch { @@ -81,6 +84,7 @@ async function healthStatus(): Promise { */ async function createTestIntent(): Promise { try { + // eslint-disable-next-line no-restricted-syntax -- standalone chaos runner targets its configured test service const res = await fetch(`${BASE_URL}/api/v1/intents`, { method: "POST", headers: {