diff --git a/.env.example b/.env.example index fb3868e..a85a306 100644 --- a/.env.example +++ b/.env.example @@ -372,6 +372,20 @@ 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 +# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points. +MAX_USER_SLIPPAGE_BPS=100 +MAX_PREMIUM_BPS=50 +ORACLE_FAIL_OPEN_MAX_USD=100 +ORACLE_MAX_STALENESS_MS=60000 +# Public anonymised datasets (RFC 0001). Disabled until an operator opts in. +DATASETS_ENABLED=false +DATASETS_ANONYMIZE=true +DATASETS_SALT= +DATASETS_SALT_ROTATION_HOURS=24 +DATASETS_SALT_RETENTION_WINDOWS=2 +DATASETS_PUBLIC_BUCKET=vortex-public-datasets +DATASETS_STORAGE_KIND=memory +DATASETS_LOCAL_DIR=./data/datasets # ─── WS gateway hardening (issue #455) ─────────────────────────────────────── # Inbound frames larger than this close the socket (1009). WS_MAX_PAYLOAD_BYTES=16384 diff --git a/.env.mainnet.example b/.env.mainnet.example index f95ceff..7c47e0f 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -257,6 +257,20 @@ 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 +# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points. +MAX_USER_SLIPPAGE_BPS=100 +MAX_PREMIUM_BPS=50 +ORACLE_FAIL_OPEN_MAX_USD=100 +ORACLE_MAX_STALENESS_MS=60000 +# Public anonymised datasets (RFC 0001). Disabled until an operator opts in. +DATASETS_ENABLED=false +DATASETS_ANONYMIZE=true +DATASETS_SALT= +DATASETS_SALT_ROTATION_HOURS=24 +DATASETS_SALT_RETENTION_WINDOWS=2 +DATASETS_PUBLIC_BUCKET=vortex-public-datasets +DATASETS_STORAGE_KIND=memory +DATASETS_LOCAL_DIR=./data/datasets # ─── WS gateway hardening (issue #455) ─────────────────────────────────────── # Inbound frames larger than this close the socket (1009). WS_MAX_PAYLOAD_BYTES=16384 diff --git a/.env.staging.example b/.env.staging.example index a472279..770671a 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -111,6 +111,11 @@ GUARDIAN_CONTRACT_ID= # ─── Synthetic canary (issue #496) ─────────────────────────────────────────── # Canary user + solver addresses; excluded from public stats and leaderboards. CANARY_ADDRESSES= +# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points. +MAX_USER_SLIPPAGE_BPS=100 +MAX_PREMIUM_BPS=50 +ORACLE_FAIL_OPEN_MAX_USD=100 +ORACLE_MAX_STALENESS_MS=60000 # Public anonymised datasets (docs/rfcs/0001) # Master switch for the public dataset publication job. diff --git a/.env.testnet.example b/.env.testnet.example index e69de29..304a7ce 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -0,0 +1,198 @@ +# .env.testnet.example +# +# Environment template for LOCAL DEVELOPMENT against Stellar TESTNET. +# Copy to .env and fill in any values marked with . +# +# cp .env.testnet.example .env +# +# Testnet is safe to experiment with — tokens have no real value and contract +# deployments are free via Friendbot. Never reuse testnet keys on mainnet. +# +# Closes #136 + +# ─── Database ──────────────────────────────────────────────────────────────── +# Local Docker Compose default. Adjust if you use a remote or managed DB. +DATABASE_URL=postgresql://vortex:vortex@localhost:5432/vortex?schema=public + +# ─── Server ────────────────────────────────────────────────────────────────── +PORT=4000 +NODE_ENV=development + +# ─── Stellar / Soroban ─────────────────────────────────────────────────────── +STELLAR_NETWORK=testnet +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# Testnet contract IDs — leave blank until you have deployed contracts. +# The service boots without them; on-chain write paths are no-ops when empty. +SETTLEMENT_CONTRACT_ID= +SOLVER_REGISTRY_CONTRACT_ID= + +# Testnet signing key — generate a throwaway keypair, fund it with Friendbot, +# and paste the secret seed here. Never reuse this key on mainnet. +# +# # Generate a new key: +# npx @stellar/stellar-cli keys generate local-dev --network testnet +# npx @stellar/stellar-cli keys show local-dev +# +# # Or via the SDK: +# node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())" +# +# # Fund it (testnet only): +# curl "https://friendbot.stellar.org/?addr=" +# +# Optional in development — leave blank to skip on-chain writes. +SOROBAN_SIGNING_KEY= + +# Fee percentile used when estimating Soroban inclusion fees. +# p50 is a safe default for testnet; raise to p90+ for time-sensitive mainnet txs. +SOROBAN_FEE_PERCENTILE=p50 + +# ─── CORS ──────────────────────────────────────────────────────────────────── +# Wildcard is fine for local development — tighten this in staging/production. +CORS_ORIGIN=* + +# ─── WebSocket ─────────────────────────────────────────────────────────────── +WS_MAX_CONNECTIONS=1000 + +# ─── Pluggable signer backend (issue #400) ─────────────────────────────────── +# SIGNER_BACKEND=local is the default for development. +# In production use SIGNER_BACKEND=vault and supply VAULT_ADDR + VAULT_TOKEN. +SIGNER_BACKEND=local +VAULT_ADDR= +VAULT_TOKEN= +VAULT_TRANSIT_KEY_NAME=vortex-signer +ALLOW_LOCAL_SIGNER_IN_PROD=false +# ─── Resource-exhaustion limits (issue #476) ───────────────────────────────── +# Maximum JSON nesting depth — rejects deeply-nested body attacks (default 10). +JSON_MAX_DEPTH=10 +# Maximum chain values in a single WS subscribe message (default 20). +WS_MAX_FILTER_CHAINS=20 +# Maximum active subscriptions per WS connection (default 10). +WS_MAX_SUBSCRIPTIONS=10 +# Postgres statement_timeout for standard queries in ms (default 5000). +DB_QUERY_TIMEOUT_MS=5000 +# Postgres statement_timeout for batch queries in ms (default 10000). +DB_BATCH_QUERY_TIMEOUT_MS=10000 +# Postgres statement_timeout for stats queries in ms (default 15000). +DB_STATS_QUERY_TIMEOUT_MS=15000 + +# Emergency kill-switch (issue #477) +# Postgres-backed so a pause survives a restart and reaches every replica. +KILLSWITCH_OPERATOR_TOKEN= +KILLSWITCH_REDIS_URL= +KILLSWITCH_POLL_MS=2000 +KILLSWITCH_PERSISTENCE=prisma + +# ─── Observability (optional) ──────────────────────────────────────────────── +# Leave blank to disable Sentry error reporting. +SENTRY_DSN= + +# debug | info | warn | error (defaults to "debug" in development) +LOG_LEVEL=debug + +# ── Shadow-mode divergence monitor (issue #401) ───────────────────────── +# Off by default in every environment. It runs read-only `simulateTransaction` +# calls against SETTLEMENT_CONTRACT_ID in parallel with the off-chain intent +# path and never signs or submits anything. +# +# SHADOW_SOURCE_ACCOUNT only has to be a valid Stellar public key: it is used to +# populate the source-account field of the simulated envelope and is never +# signed, never charged a fee and never broadcast. It must still be set, or +# every transition reports "contract_unconfigured". +SHADOW_MODE_ENABLED=false +SHADOW_SAMPLE_RATE=1 +SHADOW_QUEUE_MAX=256 +SHADOW_CONCURRENCY=4 +SHADOW_SOURCE_ACCOUNT= +# ─── Governance / Protocol Parameters ──────────────────────────────────────── +# On-chain governance parameters contract ID — leave blank to use code defaults. +PARAMS_CONTRACT_ID= + +# Poll interval in ms. 30 000 is fine for testnet. +PARAMS_POLL_INTERVAL_MS=30000 +# ─── Leader election ───────────────────────────────────────────────────────── +# Enable for multi-replica testnet deployments. +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= +# 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 +# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points. +MAX_USER_SLIPPAGE_BPS=100 +MAX_PREMIUM_BPS=50 +ORACLE_FAIL_OPEN_MAX_USD=100 +ORACLE_MAX_STALENESS_MS=60000 +# Public anonymised datasets (RFC 0001). Disabled until an operator opts in. +DATASETS_ENABLED=false +DATASETS_ANONYMIZE=true +DATASETS_SALT= +DATASETS_SALT_ROTATION_HOURS=24 +DATASETS_SALT_RETENTION_WINDOWS=2 +DATASETS_PUBLIC_BUCKET=vortex-public-datasets +DATASETS_STORAGE_KIND=memory +DATASETS_LOCAL_DIR=./data/datasets +# ─── 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= + +# ─── 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/.eslintrc.json b/.eslintrc.json index a7eb384..09d2712 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -49,5 +49,34 @@ } ] }, - "ignorePatterns": ["dist", "node_modules"] + "ignorePatterns": ["dist", "node_modules"], + "overrides": [ + { + "files": ["scripts/**/*.ts", "tools/**/*.ts"], + "rules": { + "no-restricted-syntax": "off" + } + }, + { + "files": [ + "src/soroban/signer-policy/policy.ts", + "src/soroban/signers/vault-transit.signer.ts" + ], + "rules": { + "no-restricted-syntax": "off" + } + }, + { + "files": ["src/common/http-egress/http-egress.service.spec.ts"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + }, + { + "files": ["src/soroban/signer-policy/policy.spec.ts"], + "rules": { + "@typescript-eslint/ban-ts-comment": "off" + } + } + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f6b4d..8a000c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s ## [Unreleased] ### Added +- Oracle-referenced `minDstAmount` validation on intent create: fair destination + value from the aggregator, rejection of slippage above `MAX_USER_SLIPPAGE_BPS` + unless the user signs `acknowledgeHighSlippage`, rejection of premium above + `MAX_PREMIUM_BPS`, and fail-open/fail-closed oracle policy + (Closes #434) - Transactional outbox for on-chain writes: `onchain_outbox` table, intent change + outbox row committed in one Prisma transaction, `OutboxRelayService` (SKIP LOCKED claims, per-intent ordering, envelope hash persisted before submit, dead-lettering with alert), diff --git a/README.md b/README.md index 1589614..65e0bd7 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ GET /api/v1/intents — list intents (filter by state, user, chain GET /api/v1/intents/open — all open intents (solver view) GET /api/v1/intents/:id — single intent GET /api/v1/intents/user/:addr — intents for a user -POST /api/v1/intents — create intent +POST /api/v1/intents — create intent (oracle-checked minDstAmount; 201 includes fairValue + slippageBps) POST /api/v1/intents/:id/accept — solver accepts POST /api/v1/intents/:id/fill — solver fills POST /api/v1/intents/:id/cancel — user cancels @@ -163,6 +163,10 @@ from those that are safe to leave at their testnet/dev defaults. | `LEADER_ELECTION_ENABLED` | Recommended (multi-replica) | `false` | Set to `true` when running N > 1 replicas to ensure singleton workers run on exactly one pod. Requires `DATABASE_URL` to point at a live Postgres instance. **Do not use PgBouncer in transaction-pooling mode** — see [Leader Election runbook](./docs/runbooks/leader-election.md). | | `LEADER_ELECTION_HEARTBEAT_MS` | Optional | `5000` | Heartbeat interval in ms. Lower = faster failover, higher DB load. Default gives ≤ 15 s failover. | | `PORT` | Optional | `4000` | Change if the container port mapping differs | +| `MAX_USER_SLIPPAGE_BPS` | Optional | `100` | Max user slippage vs oracle fair `minDstAmount` (1% default). Higher slippage requires a signed `acknowledgeHighSlippage`. | +| `MAX_PREMIUM_BPS` | Optional | `50` | Max `minDstAmount` premium above oracle fair value; always rejected above this. | +| `ORACLE_FAIL_OPEN_MAX_USD` | Optional | `100` | When oracle prices are missing/stale, intents with source notional at or below this USD amount are still created. | +| `ORACLE_MAX_STALENESS_MS` | Optional | `60000` | Price snapshots older than this are treated as unavailable. | For a production `.env` template, copy `.env.mainnet.example` — every `` value corresponds to a "required for production" row above. diff --git a/docs/adr/0003-oracle-min-dst-validation.md b/docs/adr/0003-oracle-min-dst-validation.md new file mode 100644 index 0000000..fa54e98 --- /dev/null +++ b/docs/adr/0003-oracle-min-dst-validation.md @@ -0,0 +1,38 @@ +# ADR 0003: Oracle-referenced minDstAmount validation + +- **Status**: Accepted +- **Date**: 2026-09-29 +- **Technical Story**: #434 — reject dangerously low and unfillable high `minDstAmount` values on intent creation + +## Context + +Intent creation previously accepted any positive integer `minDstAmount`. A +minimum far below oracle fair value lets a solver fill at the user's expense. +A minimum far above fair value can never fill and wastes solver attention. + +Token amounts are integer base units with heterogeneous decimals (6 / 7 / 18). +Fair value must therefore be computed in `bigint`, not IEEE-754 floats. + +## Decision + +1. `AggregatorService` produces a `PriceSnapshot` (USD prices at 8-decimal + scale plus `asOfMs`) from the token registry. +2. `validateMinDstAmount` is a pure function of the snapshot, amounts, and + config so tests do not need live RPC. +3. Slippage above `MAX_USER_SLIPPAGE_BPS` is rejected unless the user sets + `acknowledgeHighSlippage: true` and signs + `acknowledge-high-slippage:::`. +4. Premium above `MAX_PREMIUM_BPS` is always rejected. +5. When the oracle is missing or stale, intents with source notional at most + `ORACLE_FAIL_OPEN_MAX_USD` fail-open; larger notionals fail-closed. +6. No MEV protection is applied on the Stellar leg. + +Create responses include `fairValue` (dst base units, or null on fail-open) +and `slippageBps`. + +## Consequences + +- Integrators that posted 6-decimal-style minima against 7-decimal Stellar + USDC will now be rejected unless they acknowledge high slippage. +- Operators tune `MAX_USER_SLIPPAGE_BPS`, `MAX_PREMIUM_BPS`, + `ORACLE_FAIL_OPEN_MAX_USD`, and `ORACLE_MAX_STALENESS_MS`. diff --git a/docs/rate-limits.md b/docs/rate-limits.md index e640d1c..7219d96 100644 --- a/docs/rate-limits.md +++ b/docs/rate-limits.md @@ -297,6 +297,7 @@ a canonical message for every mutating action, so a wildcard | Route | Action | Canonical message | Proof required | |---|---|---|---| +| `POST /api/v1/intents` | Create with high slippage | `acknowledge-high-slippage:::` | Required only when `acknowledgeHighSlippage` is true; signed by the intent `user` | | `POST /api/v1/intents/:id/accept` | Accept | `accept::` | Valid solver signature | | `POST /api/v1/intents/:id/fill` | Fill | `fill::` | Valid solver signature | | `POST /api/v1/intents/:id/cancel` | Cancel | `cancel:` | Valid user signature | diff --git a/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index 2d3e716..e69de29 100644 --- a/docs/runbooks/on-call.md +++ b/docs/runbooks/on-call.md @@ -1,659 +0,0 @@ -# On-Call Runbook — Vortex Backend - -> **Scope:** This document covers the two most common on-call scenarios for -> `vortex-backend`: (1) Soroban RPC dependency outages and (2) a stuck or -> slow intent sweeper. -> Last updated: 2026-08-30 - ---- - -## Table of Contents - -1. [Service overview](#service-overview) -2. [What "normal" looks like](#what-normal-looks-like) -3. [SLOs and burn-rate alerts](#slos-and-burn-rate-alerts) -4. [Scenario A — Soroban RPC downtime](#scenario-a--soroban-rpc-downtime) -5. [Scenario B — Stuck or slow sweeper](#scenario-b--stuck-or-slow-sweeper) -6. [Scenario C — Emergency kill-switch](#scenario-c--emergency-killswitch-issue-477) -7. [Scenario D — Guardian emergency action](#scenario-d--guardian-emergency-action) -8. [Scenario E — Synthetic canary failing](#scenario-e--synthetic-canary-failing) -9. [Health probes](#health-probes) -10. [Scenario F — WebSocket backplane and slow consumers](#scenario-f--websocket-backplane-and-slow-consumers) -11. [Scenario G — Outbox rows dead or backlogged](#scenario-g--outbox-rows-dead-or-backlogged) -12. [Key configuration](#key-configuration) -13. [Escalation path](#escalation-path) - ---- - -## Service overview - -`vortex-backend` is a NestJS HTTP + WebSocket service that: - -- Accepts swap intents from users via `POST /api/v1/intents` -- Brokers them to solvers over a WebSocket feed (`WS /ws`) -- Reads chain state from a Soroban RPC node (`/api/v1/chain/*`) -- Expires stale open intents every 30 seconds via `IntentsSweeperService` - -The HTTP/WS core is **fully in-memory** — a Soroban RPC outage degrades chain -read endpoints but does **not** take down the intent relay or WebSocket feed. - ---- - -## What "normal" looks like - -| Signal | Healthy value | -|---|---| -| `GET /health` | `200 { status: "ok" }` | -| `GET /api/v1/chain/health` | `200` with Soroban `status: "healthy"` | -| Sweeper log (every 30 s) | Debug line: `sweep complete: expired=N duration=Xms` | -| `vortex_sweeper_sweep_duration_ms` p99 | < 50 ms under normal load | -| `vortex_sweeper_expired_total` | Monotonically increasing; spikes expected near intent `deadline` clusters | -| WS subscriber count | Stable or slowly growing; sudden drops indicate client-side churn | -| Node.js heap | Steady-state < 200 MB; no sustained upward trend between GC cycles | - ---- - -## SLOs and burn-rate alerts (issue #480) - -Definitions: `ops/slo/slos.yaml` (OpenSLO). Generated rules: -`ops/prometheus/rules/vortex-slo.yml`, tested by `vortex-slo_test.yml`. - -| SLO | Objective | Alert | -|---|---|---| -| Relay availability | 99.9% non-5xx / 30d | `VortexHighBurnRate` (page, 1h/5m) / `VortexSlowBurnRate` (ticket, 6h/30m) | -| Intent-create latency | p95 < 500ms / 7d | `VortexCreateLatencyHigh` (ticket) | -| WS delivery latency | p95 < 1s / 7d | `VortexWsDeliveryLatencyHigh` (ticket, p99 > 2s) | -| Event-ingestion lag | < 30s 99% / 7d | `VortexIngestionLagHigh` (page) | -| Tx confirmation latency | p95 < 60s / 7d | `vortex:confirm:p95_5m` recording rule, ticket on sustained breach | -| Intent lifecycle | ≥ 95% of opened intents reach a terminal state | `VortexIntentsNotTerminating` (ticket) | -| Intent settlement | ≥ 90% of accepted intents fill | `VortexSolverFillRateLow` (ticket) | -| On-chain cutover parity | 0 outcome mismatches | `VortexShadowDivergenceDetected` (page), see `onchain-cutover.md` | - -SLIs: `vortex_http_requests_total`, `vortex_intent_create_duration_seconds`, -`vortex_ws_delivery_duration_seconds`, `vortex_event_ingestion_lag_seconds`, -`vortex_tx_confirmation_duration_seconds` (see `src/metrics/metrics.service.ts`). -Fast-burn alerts require a minimum throughput (`>100 events/h`) so low-traffic -periods do not page. - -### Dashboards for each alert (issue #481) - -Every alert above carries both a `runbook_url` and a `dashboard_url` -annotation, so the notification links straight to the graph that shows the -problem. The committed dashboards live in `ops/grafana/dashboards` and are -provisioned by the `observability` Compose profile -(`docker compose --profile observability up -d`, Grafana on -). - -| Alert | Dashboard | -|---|---| -| `VortexHighBurnRate`, `VortexSlowBurnRate`, `VortexCreateLatencyHigh` | `vortex-api-red.json` | -| `VortexIngestionLagHigh`, `VortexShadowDivergenceDetected`, `VortexShadowMonitorStarved`, `VortexShadowMonitorUnconfigured` | `vortex-onchain-pipeline.json` | -| `VortexIntentsNotTerminating` | `vortex-intent-funnel.json` | -| `VortexSolverFillRateLow` | `vortex-solver-network.json` | -| `VortexWsDeliveryLatencyHigh` | `vortex-ws-feed.json` | - -Run the local stack with `node ops/grafana/build.mjs` if the committed JSON is -stale; the dashboards are generated, not hand-edited. See -`ops/grafana/README.md`. - ---- - -## Scenario A — Soroban RPC downtime - -### Symptoms - -- `GET /api/v1/chain/health` returns `502 Bad Gateway` or hangs. -- `GET /api/v1/chain/ledger` / `GET /api/v1/chain/network` return 5xx. -- Logs contain repeated errors from `SorobanRpc.Server`: - ``` - Error: Network error: failed to fetch - // or - Error: Response code 503 (Service Unavailable) - ``` -- `GET /api/v1/chain/account/:key` returns 5xx. - -### Impact - -| Affected | Not affected | -|---|---| -| `/api/v1/chain/*` read endpoints | `/api/v1/intents` CRUD | -| On-chain account lookups | `WS /ws` intent feed | -| Future on-chain writes (roadmap) | Sweeper — runs entirely in-memory | - -The intent relay continues operating. Users and solvers can still submit and -fill intents. Only chain-read features are degraded. - -### Diagnosis steps - -1. **Confirm it is the upstream RPC**, not the service itself: - ```bash - curl -s https://soroban-testnet.stellar.org/health - # should return {"status":"healthy"} - ``` - Check the [Stellar Status page](https://status.stellar.org) for ongoing - incidents. - -2. **Check `SOROBAN_RPC_URL` is correct** in the running environment: - ```bash - # In the container / pod - echo $SOROBAN_RPC_URL - ``` - The default is `https://soroban-testnet.stellar.org`. - -3. **Check DNS** from inside the container: - ```bash - nslookup soroban-testnet.stellar.org - ``` - -4. **Check logs** for the first occurrence of the error to determine onset: - ```bash - grep -i "soroban\|rpc\|stellar" /var/log/vortex-backend.log | tail -40 - ``` - -### Remediation - -| Action | Command / step | -|---|---| -| Switch to a backup RPC endpoint | Set `SOROBAN_RPC_URL` and restart the service | -| Temporarily suppress 5xx alerts for chain endpoints | Add a monitoring exception for `/api/v1/chain/*` | -| Communicate to users | Post a degradation notice; intent relay is unaffected | - -### Recovery confirmation - -```bash -curl -s http://localhost:4000/api/v1/chain/health -# expect: {"status":"healthy",...} -``` - ---- - -## Scenario B — Stuck or slow sweeper - -### Symptoms - -- No `sweep complete` debug log for > 60 seconds (two missed intervals). -- Sweep duration metrics (`sweepDurationMs`) show p99 > 1 second. -- Open intents with `deadline` in the past are not transitioning to `expired`. -- WS clients are not receiving `intent_expired` events. -- CPU spike coincident with sweeper interval (every 30 s). - -### What a healthy sweep looks like in logs - -``` -[IntentsSweeperService] sweep complete: expired=0 duration=2ms totalExpired=42 -``` - -A sweep that has been delayed or killed will simply be absent. - -### How the sweeper works - -`IntentsSweeperService.sweep()` is triggered by a `setInterval` every -`SWEEP_INTERVAL_MS` (30 000 ms, hardcoded). It: - -1. Calls `IntentsService.getByState("open")` — iterates the in-memory store. -2. Compares each intent's `deadline` (Unix timestamp) against `Date.now()`. -3. Calls `IntentsService.update()` and `IntentsGateway.broadcast()` for each - expired intent. -4. Records `vortex_sweeper_sweep_duration_ms` and increments - `vortex_sweeper_expired_total` via `MetricsService.recordSweep()` (Prometheus, - exposed on `GET /metrics`). The retired `MetricsRegistry` from - `src/common/metrics.ts` has been removed (issue #259) — use the - Prometheus metric names above for alerting and dashboards. - -Because the store is in-memory and the loop is synchronous, the sweep should -complete in **single-digit milliseconds** for < 10 000 open intents. - -### Possible causes and fixes - -| Cause | Indicator | Fix | -|---|---|---| -| Node.js event loop blocked | Sweep log missing, but service still responding to HTTP | Profile with `clinic flame` or `node --prof`; identify the blocking call | -| `setInterval` not firing (module destroyed prematurely) | `onModuleDestroy` called without `onModuleInit` | Investigate graceful-shutdown lifecycle; restart the process | -| Runaway open-intent accumulation | `IntentsService.getByState("open")` returning tens of thousands of items | Investigate why intents are not being filled/cancelled; a per-user cap of **50 simultaneous open/accepted intents** (`MAX_OPEN_INTENTS_PER_USER` in `src/intents/intents.service.ts`) is enforced at creation time — if you see accumulation beyond this per-user limit investigate whether the cap enforcement path (HTTP 409 on `POST /api/v1/intents`) is reachable, or whether old seed/test data was inserted directly into the store | -| Broadcast fan-out stalling | `IntentsGateway.broadcast()` slow due to thousands of WS subscribers | Reduce subscriber count or move to async fan-out; see issue #84 load-test results | -| Clock skew | All intents appear non-expired despite past deadlines | Verify `Date.now()` on the server and compare against intent `deadline` values; fix NTP | - -### Manual sweep trigger (emergency) - -The service installs a **`SIGUSR2` handler** that runs exactly one -`IntentsSweeperService.sweep()` cycle on demand. This is the supported -break-glass mechanism — do **not** attach a Node.js REPL to the process. - -**Why a signal and not an HTTP endpoint:** it requires shell access to the -host (so it is inherently operator-only and unreachable by any API client), -needs no separate secret to manage, and every invocation is logged loudly so -it shows up clearly in the incident timeline. - -```bash -# 1. Find the backend PID -pgrep -f "node dist/main.js" - -# 2. Trigger one sweep cycle -kill -USR2 -# In Kubernetes: -# kubectl exec -- kill -USR2 1 -``` - -The trigger is synchronous and idempotent — sending `SIGUSR2` again simply -runs another cycle. Confirm it ran by grepping the logs: - -```bash -grep "MANUAL SWEEP" /var/log/vortex-backend.log | tail -5 -# [sweeper] MANUAL SWEEP TRIGGERED (source=SIGUSR2, invokedAt=...) — running one sweep cycle -# [sweeper] MANUAL SWEEP COMPLETE (source=SIGUSR2, invokedAt=...): expired=N slashed=M duration=Xms -``` - -If a manual sweep is needed repeatedly, the sweeper's own 30-second interval -is broken — escalate to the service owner rather than scripting the signal. - -### Diagnosis steps - -1. **Check the last sweep timestamp** in logs: - ```bash - grep "sweep complete" /var/log/vortex-backend.log | tail -5 - ``` - -2. **Check current open-intent count** via the API: - ```bash - curl -s http://localhost:4000/api/v1/intents/open | jq '.intents | length' - ``` - A very large number (> 1 000) with many past-deadline entries confirms the - sweeper is not running. - -3. **Check metrics** (if a metrics endpoint is wired up): - ```bash - curl -s http://localhost:4000/metrics | grep sweeper - # vortex_sweeper_sweep_duration_ms_count - # vortex_sweeper_sweep_duration_ms_sum - # vortex_sweeper_expired_total - ``` - -4. **Inspect process health**: - ```bash - # CPU and memory - top -p $(pgrep -f "node dist/main.js") - - # Open file descriptors (WS connections count as FDs) - ls /proc/$(pgrep -f "node dist/main.js")/fd | wc -l - ``` - -### Recovery confirmation - -After a restart or fix, confirm: - -```bash -# 1. Service is responding -curl -s http://localhost:4000/health - -# 2. Sweep fires within 30 s — watch for the log line -journalctl -fu vortex-backend | grep "sweep complete" - -# 3. Past-deadline intents are now expired -curl -s http://localhost:4000/api/v1/intents?state=open | jq '[.intents[] | select(.deadline < now)] | length' -# should be 0 -``` - ---- - -## Scenario C - Emergency kill-switch (issue #477) - -Use this whenever the safe move is to stop writing: a depegged token, a -compromised or misbehaving solver, a chain/RPC incident, or any anomaly where you -would rather freeze than keep settling. - -The full procedure, API reference, and failure modes are in -**[killswitch.md](./killswitch.md)**. The short version: - -```bash -KS=http://localhost:4000/api/v1/ops/killswitch - -# 1. Inspect current state first. -curl -s "$KS" -H "x-operator-token: $TOKEN" | jq - -# 2. Pause the narrowest scope that covers the problem. Start narrow. -curl -sX POST "$KS/pause" \ - -H "x-operator-token: $TOKEN" -H "x-operator-id: $ME" \ - -H 'content-type: application/json' \ - -d '{"scope":"chain","chain":"stellar","reasonCode":"CHAIN_DEGRADED","reason":"RPC errors >20%"}' -``` - -Scopes are `global`, `chain`, `token`, and `operation`; operations are `create`, -`accept`, `fill`, `slash`, and `onchain`. Pausing `onchain` stops every write -that reaches the chain. - -### Verifying it took effect - -Blocked writes return **503** with `Retry-After` and a `reason` code. Check -every replica, not just the one you called: - -```bash -for port in 4000 4001 4002; do - curl -s localhost:$port/health | jq -c '{port:'"$port"', killswitch}' -done -``` - -A pause is an operational state, not an outage — `/health` keeps reporting -`status: "ok"`. Use the `killswitch` block, and the `propagation` field, to tell -"paused" apart from "healthy". - -Watch the sweeper logs: while `fill` is paused it must log deadline extensions -rather than slashes. - -```bash -docker logs vortex 2>&1 | grep -E "Kill-switch|sweeper.*paused" -``` - -### Resuming - -Two **different** operators must approve, and the broadest scope goes first: - -```bash -curl -sX POST "$KS/resume/$SWITCH_ID" -H "x-operator-token: $TOKEN" \ - -H "x-operator-id: $ME" -H 'content-type: application/json' -d '{}' -# -> {"resumed": false, "approvals": 1, "required": 2} -``` - -`resumed: false` means "recorded, not yet reopened" — that is expected after the -first approval. - -### If the switch itself is the problem - -If writes are being refused but no switch is listed, the replica has not loaded -its snapshot and is failing closed. `ready: false` in `/health` points at the -database. See "Failure modes" in [killswitch.md](./killswitch.md). - -## Scenario D — Guardian emergency action - -The backend ingests emergency actions from the on-chain guardian / security -council contract (`GUARDIAN_CONTRACT_ID`, issue #507) and applies them within -one poll (10 s) on every instance: - -| Guardian event | Backend effect | -|---|---| -| `guardian_pause` / `guardian_unpause` | Acts as a global kill switch (reason `GUARDIAN_PAUSE`): every kill-switch-gated write returns **503**. Cancels still work. | -| `guardian_freeze` / `guardian_unfreeze` (target = flag key or `*`) | Runtime feature flag changes for that key return **409**. | -| `guardian_blacklist` / `guardian_unblacklist` (target = solver) | Solver cannot accept intents (**403**); operator reactivation returns **409**. | - -### Check status - -```bash -curl -s http://localhost:4000/api/v1/governance/guardian/status | jq -# { paused, guardianPaused, operatorSwitches: [...], guardianActions: [{ id, kind, target, txHash, ledger, activatedAt }], ... } -``` - -Every active action carries its `txHash` — confirm it on a block explorer -before acting. - -### Rules while a guardian action is active - -- Guardian state is **authoritative**. Operators cannot clear it: resuming - operator kill switches (Scenario C), solver reactivation and flag edits on a - frozen key do not lift guardian state. -- The guardian pause is evaluated independently of operator switches. A - guardian unpause while an operator switch is active leaves writes paused - (and vice versa) — **both must clear**. - -### Manual override (break-glass) - -Only for a confirmed false positive, with sign-off from the security council. -Requires a **superadmin** key and is refused unless the audit record is -written (`admin_audit_log`, action `guardian.override`): - -```bash -curl -X POST -H "x-admin-key: $SUPERADMIN_KEY" -H 'content-type: application/json' \ - -d '{"reason":"false positive, council ack in #sec-incident"}' \ - http://localhost:4000/api/v1/governance/guardian/actions//override -``` - -The override lasts until the guardian emits a new action for the same target. - ---- - -## Scenario E — Synthetic canary failing - -Alerts `VortexCanaryConsecutiveFailures`, `VortexCanaryFundsLow`, -`VortexCanaryBudgetExceeded` come from the canary CronJob -(`tools/canary/`, issue #496). - -1. Check `vortex_canary_step_duration_seconds{step=...}` for the last step - that reported — the failing step is the one after it — and the CronJob pod - logs (`[canary] FAILED ...` names the HTTP call and status). -2. `create`/`accept`/`fill` returning 503 → check Scenarios C and D (paused). -3. `fill` failing on-chain → canary funds or trustline issue; check - `vortex_canary_balance_xlm`. -4. Funds low / budget exceeded → suspend the CronJob - (`kubectl patch cronjob -p '{"spec":{"suspend":true}}'`), top up the - canary solver account, investigate fee spikes before resuming. - -Canary intents are excluded from public stats and leaderboards via -`CANARY_ADDRESSES`; if they show up there, that variable is missing on the API. -## Scenario F — WebSocket backplane and slow consumers - -**Backplane (issue #454).** With `WS_BACKPLANE=redis` every replica publishes -events into a Redis stream (`vortex:ws:events`) with a global sequence number -(`vortex:ws:seq`) and delivers from that stream, so clients on any replica -see the same events, in the same order, with the same `seq`, and replay works -against any replica. Publishing is queued in the background — request -handlers never wait for Redis. - -- **Redis down:** `/health/ready` on WS-role pods goes 503 (`ws_backplane` - down) and `vortex_ws_backplane_connected` drops to 0. Publishes queue - (bounded) and are retried in order; on recovery each replica resumes the - stream from the last event it delivered — no loss, duplicates or - reordering. Watch `vortex_ws_backplane_dropped_total{reason="queue_full"}` - for events dropped during a long outage. -- **Latency:** `vortex_ws_backplane_publish_duration_seconds`. - -**Connection limits and slow consumers (issue #455).** - -- Connections over `WS_MAX_CONNECTIONS` or `WS_MAX_CONNECTIONS_PER_IP` are - closed with 1013 (`vortex_ws_connections_rejected_total{reason}`). Behind a - load balancer set `WS_TRUST_PROXY_HOPS` to the number of proxies, or every - client shares the proxy's IP. -- Clients over the inbound token bucket get `rate_limited` frames and are - closed with 1008 after `WS_RATE_LIMIT_MAX_VIOLATIONS` - (`vortex_ws_rate_limited_total{action}`). Frames over - `WS_MAX_PAYLOAD_BYTES` close the socket with 1009. -- Slow consumers: once a socket's buffer passes `WS_OUTBOUND_BUFFER_BYTES`, - messages queue (at most `WS_OUTBOUND_QUEUE_MAX`); beyond that the oldest are - dropped (`vortex_ws_outbound_dropped_total`) or, with - `WS_SLOW_CONSUMER_POLICY=disconnect`, the client is closed - (`vortex_ws_slow_consumer_disconnects_total`). Clients recover dropped - events with `replay` — the solver SDK does this automatically. -- Solvers can authenticate with a SEP-10 JWT (`?token=`, `Authorization: - Bearer`, or `{ "type": "auth", "token" }`) when `AUTH_JWT_SECRET` is set, - in addition to signed `auth` frames. Anonymous connections still receive - the public feed. - ---- - -## Scenario G — Outbox rows dead or backlogged - -On-chain writes (intent create/accept/fill/cancel) are committed to the -`onchain_outbox` table in the same transaction as the intent change, and -`OutboxRelayService` submits them afterwards (issue #396). Alert rules live in -[`alerts/onchain-writes.rules.yml`](alerts/onchain-writes.rules.yml). - -### Symptoms - -- `VortexOutboxRowDead`: `vortex_outbox_dead_total` increased. Logs contain - `[outbox] ALERT row (...) moved to dead after N attempts: `. -- `VortexOutboxBacklog`: `vortex_outbox_rows{status="pending"}` keeps growing. - -### Impact - -A dead row **blocks every later row for the same intent** (per-intent -ordering), so that intent's on-chain state stops advancing. Other intents -are unaffected. The API keeps serving from the database. - -### Diagnosis - -```sql -SELECT id, intent_id, operation, attempts, last_error, updated_at -FROM onchain_outbox WHERE status = 'dead' ORDER BY id; - -SELECT status, count(*) FROM onchain_outbox GROUP BY status; -``` - -| `last_error` | Likely cause | -|---|---| -| `SETTLEMENT_CONTRACT_ID is not configured` | Env misconfiguration | -| `signer is not configured` | `SOROBAN_SIGNING_KEY` missing | -| `sendTransaction returned ERROR` / `simulation error` | Contract rejected the call — inspect the payload | -| `RPC ...` / timeouts | Scenario A (RPC downtime) | - -Backlog with no dead rows usually means the relay is off -(`OUTBOX_RELAY_ENABLED=false` — look for `[outbox] relay disabled` at boot) -or Scenario A. - -### Remediation - -Fix the root cause first, then requeue (resets attempts; the intent unblocks): - -```bash -curl -s -X POST -H "x-admin-key: $ADMIN_KEY" \ - "$API/api/v1/admin/outbox//requeue" -``` - -Never delete outbox rows or edit `status` by hand while the relay is running; -the relay's writes are fenced on `attempts` and a manual edit can be -overwritten. A row whose operation must be abandoned (e.g. the contract will -never accept it) needs a code/ADR decision — escalate. - -### Crash safety (why duplicates don't happen) - -The relay stores the signed envelope hash before broadcasting. After a crash -the row is reclaimed once `OUTBOX_LEASE_SECONDS` (default 120 s, longer than -the 30 s transaction time bound) has passed; the relay looks that hash up and -confirms the row if it landed, instead of resubmitting. - ---- - -## Health probes - -Issue #492. All three probes read results cached by a background checker -(every `HEALTH_CHECK_INTERVAL_MS`), so they answer in < 50 ms whatever the -dependencies are doing. - -| Endpoint | Meaning | Fails (503) when | -|---|---|---| -| `GET /health/live` | Process is responsive | Event-loop delay > `HEALTH_EVENT_LOOP_MAX_LAG_MS`. Dependency outages never fail it, so pods are not restarted during an outage. | -| `GET /health/ready` | Can serve its roles (`SERVICE_ROLES`) | An indicator critical to one of those roles is down for `HEALTH_READY_FAILURE_THRESHOLD` consecutive checks. It turns ready again after `HEALTH_READY_SUCCESS_THRESHOLD` passes (hysteresis). `status: "degraded"` = a non-critical dependency is down. | -| `GET /health/startup` | Migrations applied, caches warmed | Until every startup indicator has passed once. | -| `GET /health` | Legacy aggregate (unchanged, plus `backplane`) | Never | - -Indicators (`indicators` in the `/health/ready` body, with `critical`, `error`, `details`): - -| Indicator | Critical for | Notes | -|---|---|---| -| `database` | api, worker — only when a `*_PERSISTENCE=prisma` adapter is used | `SELECT 1` | -| `migrations` | startup | Every directory in `prisma/migrations` is applied in `_prisma_migrations` | -| `soroban_rpc_quorum` | worker | Majority of `SOROBAN_RPC_HEALTH_URLS` answer `getHealth` = healthy | -| `ws_backplane` | ws — when `WS_BACKPLANE=redis` | Replica can read the Redis stream | -| `killswitch_snapshot` | api, startup | Kill-switch snapshot loaded (writes fail closed without it) | - -Metrics: `vortex_health_ready`, `vortex_health_indicator_up{indicator}`, -`vortex_health_check_duration_seconds{indicator}`. - -Kubernetes probes (split deployments set `SERVICE_ROLES` per workload, e.g. -`api,ws` for HTTP pods and `worker` for workers): - -```yaml -startupProbe: - httpGet: { path: /health/startup, port: 4000 } - periodSeconds: 5 - failureThreshold: 60 # up to 5 min for migrations + cache warm-up -livenessProbe: - httpGet: { path: /health/live, port: 4000 } - periodSeconds: 10 - failureThreshold: 3 -readinessProbe: - httpGet: { path: /health/ready, port: 4000 } - periodSeconds: 5 - failureThreshold: 1 # hysteresis is applied server-side -``` - ---- - -## Key configuration - -| Variable | Default | Effect | -|---|---|---| -| `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Upstream Soroban JSON-RPC endpoint | -| `STELLAR_NETWORK` | `testnet` | Network passphrase selection | -| `PORT` | `4000` | HTTP + WS listen port | -| `NODE_ENV` | `development` | Log verbosity (set to `production` in prod) | -| `SWEEP_INTERVAL_MS` | `30000` (hardcoded) | How often the sweeper runs; change requires code deploy | -| `KILLSWITCH_OPERATOR_TOKEN` | empty (control plane disabled) | Secret for `/api/v1/ops/killswitch`; **required in production** | -| `KILLSWITCH_REDIS_URL` | `REDIS_URL` when `WS_BACKPLANE=redis` | Cross-replica pause propagation; empty = poll only | -| `KILLSWITCH_POLL_MS` | `2000` | DB change-probe interval backing up Redis; caps propagation delay | -| `KILLSWITCH_PERSISTENCE` | `memory` | `prisma` in production, or a pause is lost on restart | -| `GUARDIAN_CONTRACT_ID` | empty (disabled) | Guardian contract polled for emergency actions | -| `ADMIN_API_KEYS` | empty (admin APIs disabled) | `id:role:secret` entries for admin / superadmin endpoints | -| `PROCESS_ROLE` / `JOBS_DRIVER` | `all` / `memory` | Where job workers run; `bullmq` for multi-instance | -| `CANARY_ADDRESSES` | empty | Canary accounts excluded from public stats | -| `OUTBOX_RELAY_ENABLED` | `true` | Kill switch for the outbox relay; rows accumulate while off | -| `OUTBOX_MAX_ATTEMPTS` | `8` | Claims before an outbox row is dead-lettered | -| `OUTBOX_LEASE_SECONDS` | `120` | Crash-reclaim delay; must exceed the 30 s tx time bound | -| `SLASH_CHALLENGE_WINDOW_SECONDS` | `600` | Delay before a detected slash may be broadcast — see [slash-cancellation.md](slash-cancellation.md) | - ---- - -## Escalation path - -1. **On-call engineer** — check this runbook and attempt the listed remediation steps. -2. **Service owner** — if the sweeper is structurally broken (not just slow) or if the Soroban outage persists > 30 minutes. -3. **Stellar / Horizon team** — if `soroban-testnet.stellar.org` is confirmed down; follow [Stellar Discord #dev-support](https://discord.gg/stellardev). - -> For production incidents open a severity-1 ticket and page the service owner -> via the alerting system. - ---- - -## Inspecting Stuck Transactions (#386) - -Transactions in `pending_transactions` with `status = 'pending'` and `next_poll_at` in the past are being actively retried by `TxConfirmationService`. Normal retries use exponential backoff up to `max_track_until`. - -### Find all stuck transactions - -```sql -SELECT tx_hash, intent_id, attempts, fee_bump_count, - to_timestamp(next_poll_at) AS next_poll_at_ts, - to_timestamp(max_track_until) AS expires_at, - created_at -FROM pending_transactions -WHERE status = 'pending' - AND next_poll_at < extract(epoch FROM now()) -ORDER BY next_poll_at ASC -LIMIT 50; -``` - -### Force-expire a stuck transaction - -```sql -UPDATE pending_transactions -SET status = 'expired', updated_at = now() -WHERE tx_hash = ''; -``` - -### Inspect dead-lettered events (#389) - -```sql -SELECT ledger, event_index, contract_id, network, last_error, attempts, created_at -FROM dead_letter_events -ORDER BY created_at DESC -LIMIT 20; -``` - -### Key Prometheus metrics - -| Metric | Alert threshold | -|--------|----------------| -| `vortex_tx_confirmation_outcomes_total{status="confirmed\|failed\|expired"}` | — (informational) | -| `vortex_tx_confirmation_latency_seconds` | p99 > 120 s | -| `vortex_tx_fee_bump_total{percentile}` | — (informational) | -| `vortex_tx_fee_bump_ceiling_hits_total` | > 0 (alert) | -| `vortex_channel_pool_utilisation` | > 0.9 sustained | -| `vortex_channel_bad_seq_resyncs_total` | spike > 10/min | -| `vortex_ingestion_cursor_lag_ledgers` | > 200 ledgers | -| `vortex_ingestion_dead_letter_total` | > 0 (alert) | diff --git a/jest.config.js b/jest.config.js index aab1545..e69de29 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,66 +0,0 @@ -/** @type {import('jest').Config} */ - -// The single definition of the coverage gate. It is enforced on the *merged* -// shard report by scripts/ci/coverage-merge.mjs, not by individual shard runs -// (issue #486), which pass --coverageThreshold '{}' because a shard only ever -// executes part of the suite. -module.exports = { - preset: "ts-jest", - testEnvironment: "node", - rootDir: "src", - testRegex: ".*\\.spec\\.ts$", - collectCoverageFrom: ["**/*.(t|j)s"], - coverageDirectory: "../coverage", - // text-summary keeps local runs readable, lcov feeds editors, and json is what - // coverage-merge.mjs consumes. - coverageReporters: ["text-summary", "lcov", "json"], - // Run both the main NestJS unit suite and the scripts suite under one command. - projects: [ - // ── Main NestJS unit suite ────────────────────────────────────────────── - { - displayName: "src", - preset: "ts-jest", - testEnvironment: "node", - rootDir: "src", - testRegex: ".*\\.spec\\.ts$", - // Exclude the scripts sub-suite so tests aren't picked up twice. - testPathIgnorePatterns: ["/scripts/"], - collectCoverageFrom: ["**/*.(t|j)s"], - moduleNameMapper: { - "^@nestjs/schedule$": "/../test/__mocks__/@nestjs/schedule.ts", - }, - }, - - // ── Scripts suite (ledger-utils, etc.) ───────────────────────────────── - // Tests live in src/scripts/ but import from scripts/ (outside src/). - // A dedicated tsconfig with broader rootDir handles the path. - { - displayName: "scripts", - testEnvironment: "node", - rootDir: ".", - testMatch: ["/src/scripts/**/*.spec.ts"], - transform: { - "^.+\\.tsx?$": [ - "ts-jest", - { - tsconfig: "./tsconfig.scripts.json", - }, - ], - }, - moduleNameMapper: { - "^@nestjs/schedule$": "/test/__mocks__/@nestjs/schedule.ts", - }, - }, - ], - - // Coverage is collected from the project-level collectCoverageFrom above. - coverageDirectory: "coverage", - coverageThreshold: { - global: { - branches: 70, - functions: 70, - lines: 70, - statements: 70, - }, - }, -}; diff --git a/src/common/stellar-signature.contract.spec.ts b/src/common/stellar-signature.contract.spec.ts index f8300f1..fe19c33 100644 --- a/src/common/stellar-signature.contract.spec.ts +++ b/src/common/stellar-signature.contract.spec.ts @@ -3,6 +3,7 @@ import { buildAcceptMessage, buildCancelMessage, buildFillMessage, + buildHighSlippageAckMessage, buildRegisterMessage, buildSolverStatusMessage, verifyStellarSignature, @@ -20,6 +21,11 @@ const messageBuilders: Array<{ name: string; builder: (...args: any[]) => string builder: buildSolverStatusMessage, args: ["deactivate", VALID_PUBLIC_KEY], }, + { + name: "buildHighSlippageAckMessage", + builder: buildHighSlippageAckMessage, + args: [VALID_PUBLIC_KEY, "1000000", "5000000"], + }, ]; describe("stellar-signature message contract", () => { diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index 20fc23b..e69de29 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -1,179 +0,0 @@ -/** - * Stellar keypair signature verification helper. - * - * Convention used throughout this project: - * message = the canonical string that was signed - * signature = base64-encoded 64-byte Ed25519 signature produced by - * Keypair.sign(Buffer.from(message)) - * - * The signer proves control of `publicKey` by supplying a valid signature - * over the message. We never trust a caller-supplied address alone. - */ -import { Keypair } from "@stellar/stellar-sdk"; -import { UnauthorizedException } from "@nestjs/common"; -import { createHash } from "node:crypto"; - -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", - /** - * Build the canonical message that a solver must sign to update their mutable - * profile fields (name / supportedChains / supportedTokens / avgFillTime). - 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 sigBytes = Buffer.from(signature, "base64"); - const valid = keypair.verify(messageBytes, sigBytes); - if (!valid) { - throw new UnauthorizedException("Signature verification failed"); - } - } catch (err) { - if (err instanceof UnauthorizedException) throw err; - // Invalid public key, bad base64, etc. - throw new UnauthorizedException("Invalid signature or public key"); - } -} - -/** - * Build the canonical message that a user must sign to cancel an intent. - */ -export function buildCancelMessage(intentId: string, context?: IntentSignatureContext, user?: string): string { - if (context) return buildV2IntentMessage(context, "cancel", intentId, { user: user ?? "" }); - return `cancel:${intentId}`; -} - -/** - * Build the canonical message that a solver must sign to authenticate its WS connection. - */ -export function buildWsAuthMessage(solver: string, timestamp: number | string): string { - return `solver-auth:${solver}:${String(timestamp)}`; -} - -/** - * Build the canonical message that a solver must sign to accept an intent. - */ -export function buildAcceptMessage(intentId: string, solver: string, context?: IntentSignatureContext): string { - if (context) return buildV2IntentMessage(context, "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, - 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}`; -} - -/** - * Build the canonical message that a solver must sign to register. - */ -export function buildRegisterMessage(address: string): string { - return `register:${address}`; -} - -/** - * Build the canonical message that a solver must sign to change status. - */ -export function buildSolverStatusMessage(action: "deactivate" | "reactivate" | "deregister", address: string): string { - return `${action}:${address}`; -} - -/** - * Build the canonical message that a solver must sign to update its own - * mutable profile fields (name / supportedChains / supportedTokens / - * avgFillTime — issue #273, `PATCH /api/v1/solvers/:address`). - * - * 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}`; -} - -/** - * Build the canonical message that a solver must sign to submit a slash dispute. - */ -export function buildDisputeMessage(slashId: string, address: string, reason: string): string { - return `dispute:${slashId}:${address}:${reason}`; -} - -/** - * Build the canonical message a reviewer must sign to move a dispute into review. - */ -export function buildDisputeReviewMessage(disputeId: string): string { - return `dispute-review:${disputeId}`; -} - -/** - * Build the canonical message a reviewer must sign to decide a dispute. - */ -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}`; -} - -/** - * Canonical message a solver signs to prove a fill landed in time and cancel - * a pending slash during its challenge window (issue #397). - */ -export function buildFillProofMessage(intentId: string, solver: string, txHash: string): string { - return `fill-proof:${intentId}:${solver}:${txHash}`; -} diff --git a/src/governance/governance.module.ts b/src/governance/governance.module.ts index 0ebaac8..e69de29 100644 --- a/src/governance/governance.module.ts +++ b/src/governance/governance.module.ts @@ -1,32 +0,0 @@ -import { forwardRef, Module } from "@nestjs/common"; -import { Module, forwardRef } from "@nestjs/common"; -import { ProtocolParamsService } from "./params.service"; -import { ParamsController } from "./params.controller"; -import { SorobanModule } from "../soroban/soroban.module"; -import { GuardianController } from "./guardian.controller"; -import { GuardianService } from "./guardian.service"; - -// GovernanceModule → SorobanModule → IntentsModule → GovernanceModule forms a -// cycle; the SorobanModule edge must be deferred so the import resolves after -// SorobanModule has finished loading. - -/** - * Governance module — exposes protocol parameters sourced from the on-chain - * governance / parameters contract, and ingests guardian emergency actions - * (issue #507). - * - * Exports `ProtocolParamsService` so other modules (e.g. `IntentsModule`) can - * inject it to snapshot parameters at intent-creation time. - * - * `SorobanModule` is imported through `forwardRef`: SorobanModule <-> - * IntentsModule is an existing CommonJS cycle, and IntentsModule imports this - * module, so a bare import would resolve to `undefined` while SorobanModule is - * still mid-initialization. - */ -@Module({ - imports: [forwardRef(() => SorobanModule)], - controllers: [ParamsController, GuardianController], - providers: [ProtocolParamsService, GuardianService], - exports: [ProtocolParamsService, GuardianService], -}) -export class GovernanceModule {} diff --git a/src/intents/dto/create-intent.dto.ts b/src/intents/dto/create-intent.dto.ts index 3b4e592..e69de29 100644 --- a/src/intents/dto/create-intent.dto.ts +++ b/src/intents/dto/create-intent.dto.ts @@ -1,179 +0,0 @@ -import { - IsIn, - IsInt, - IsOptional, - IsString, - Matches, - Max, - MaxLength, - Min, - MinLength, - ValidateNested, - registerDecorator, - ValidationArguments, - ValidationOptions, -} from "class-validator"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Type } from "class-transformer"; -import { SUPPORTED_CHAINS, SupportedChain } from "../intents.types"; -import { IsValidAddress } from "../../common/validators/is-valid-address.validator"; -import { IsValidDeadline } from "../../common/validators/deadline.validator"; - -export class DutchAuctionDto { - @ApiProperty({ description: "Starting destination amount in base units" }) - @IsString() - @Matches(/^\d+$/) - startDstAmount!: string; - - @ApiProperty({ description: "Unix timestamp when the price decay starts" }) - @IsInt() - @Min(1) - @Max(4102444800) - decayStart!: number; - - @ApiProperty({ description: "Unix timestamp when the price reaches minDstAmount" }) - @IsInt() - @Min(1) - @Max(4102444800) - decayEnd!: number; - - @ApiPropertyOptional({ description: "Solver address exclusively eligible until exclusivityEnd" }) - @IsOptional() - @IsString() - @Matches(/^(G[A-Z2-7]{55}|0x[a-fA-F0-9]{40})$/) - @MaxLength(56) - exclusiveSolver?: string; - - @ApiPropertyOptional({ description: "Unix timestamp when solver exclusivity ends" }) - @IsOptional() - @IsInt() - @Min(1) - @Max(4102444800) - exclusivityEnd?: number; -} - -function IsNotSelfSwap(validationOptions?: ValidationOptions) { - return function (object: object, propertyName: string) { - registerDecorator({ - name: "isNotSelfSwap", - target: object.constructor, - propertyName, - options: validationOptions, - validator: { - validate(value: unknown, args: ValidationArguments) { - const obj = args.object as Record; - const srcChain = obj.srcChain; - const srcTokenAddress = obj.srcTokenAddress; - const dstTokenContract = value; - - return !(srcChain === "stellar" && srcTokenAddress === dstTokenContract); - }, - defaultMessage() { - return "Self-swaps are not allowed: a Stellar asset cannot be swapped against itself on the same chain"; - }, - }, - }); - }; -} - -export class CreateIntentDto { - @ApiProperty({ description: "Stellar or EVM address of the user creating the intent", maxLength: 56 }) - @IsString() - @MinLength(10) - @MaxLength(56) - user!: string; - - @ApiProperty({ enum: SUPPORTED_CHAINS, description: "Source chain the funds are coming from" }) - @IsIn(SUPPORTED_CHAINS) - srcChain!: SupportedChain; - - @ApiProperty({ description: "Source token contract/address on srcChain" }) - @IsValidAddress() - srcTokenAddress!: string; - - @ApiProperty({ description: "Source token symbol, e.g. USDC", maxLength: 16 }) - @IsString() - @MaxLength(16) - srcTokenSymbol!: string; - - @ApiProperty({ minimum: 0, maximum: 18, description: "Source token decimals" }) - @IsInt() - @Min(0) - @Max(18) - srcTokenDecimals!: number; - - @ApiProperty({ description: "Source amount as a non-negative integer string (base units)" }) - @IsString() - @Matches(/^\d+$/) - srcAmount!: string; - - @ApiProperty({ description: "Destination Stellar token contract", maxLength: 56 }) - @IsString() - @Matches(/^[A-Z0-9]{56}$/) - @MaxLength(56) - @IsNotSelfSwap() - dstTokenContract!: string; - - @ApiProperty({ description: "Destination token symbol, e.g. USDC", maxLength: 16 }) - @IsString() - @MaxLength(16) - dstTokenSymbol!: string; - - @ApiProperty({ minimum: 0, maximum: 18, description: "Destination token decimals" }) - @IsInt() - @Min(0) - @Max(18) - dstTokenDecimals!: number; - - @ApiProperty({ description: "Minimum acceptable destination amount as an integer string" }) - @IsString() - @Matches(/^\d+$/) - minDstAmount!: string; - - @ApiPropertyOptional({ type: DutchAuctionDto, description: "Optional Dutch-auction allocation terms" }) - @IsOptional() - @ValidateNested() - @Type(() => DutchAuctionDto) - auction?: DutchAuctionDto; - - @ApiPropertyOptional({ description: "Unix timestamp deadline; defaults to now + 1800s; must be between now+60s and now+24h" }) - @IsOptional() - @IsInt() - @IsValidDeadline() - deadline?: number; - - @ApiPropertyOptional({ description: "EIP-712 signature for EVM-originated intent creation", maxLength: 4096 }) - @IsOptional() - @IsString() - @Matches(/^0x(?:[0-9a-fA-F]{2})+$/) - @MaxLength(4096) - signature?: string; - - @ApiPropertyOptional({ description: "Unique nonce included in the EIP-712 signature", maxLength: 128 }) - @IsOptional() - @IsString() - @Matches(/^[A-Za-z0-9_x-]{16,128}$/) - nonce?: string; - - @ApiPropertyOptional({ description: "Unix timestamp when the EIP-712 signature expires" }) - @IsOptional() - @IsInt() - @Min(1) - @Max(4102444800) - expiresAt?: number; - - @ApiPropertyOptional({ description: "Idempotency key for deduplicating duplicate requests" }) - @IsOptional() - @IsString() - idempotencyKey?: string; - - @ApiPropertyOptional({ - description: - "EVM transaction hash of the escrow deposit (issue #403). Optional: when supplied, verification " + - "reads that receipt directly instead of scanning recent blocks for the Deposited log.", - example: "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060", - }) - @IsOptional() - @Matches(/^0x[0-9a-fA-F]{64}$/, { message: "srcTxHash must be a 0x-prefixed 32-byte transaction hash" }) - srcTxHash?: string; -} diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 36e66f6..e69de29 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -1,760 +0,0 @@ -import { - BadRequestException, - Body, - ConflictException, - Controller, - ForbiddenException, - Get, - GoneException, - NotFoundException, - Param, - Post, - Query, - ServiceUnavailableException, - UseGuards, -} from "@nestjs/common"; -import { - ApiTags, - ApiOkResponse, - ApiNotFoundResponse, - ApiConflictResponse, - ApiForbiddenResponse, - ApiGoneResponse, - ApiBadRequestResponse, - ApiTooManyRequestsResponse, - ApiOperation, - ApiServiceUnavailableResponse, -} 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 } 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 { 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"; -import { SolverBondService } from "../soroban/solver-bond.service"; -import { ProtocolParamsService } from "../governance/params.service"; -import { baseUnitsToUsdMicros, intentExposureUsdMicros } from "./intent-exposure"; - -@ApiTags("intents") -@Controller("api/v1/intents") -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, - private readonly solverBondService: SolverBondService, - private readonly protocolParamsService: ProtocolParamsService, - ) { - 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(":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" }) - @ApiServiceUnavailableResponse({ description: "Solver bond or a fresh USD price could not be verified" }) - 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); - - 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 chainBond = await this.solverBondService.getBond(dto.solver); - if (!chainBond.isActive || chainBond.bondAmount <= 0n) { - throw new ForbiddenException({ - code: "INSUFFICIENT_BOND", - error: "Solver has no active on-chain bond", - message: "Solver has no active on-chain bond", - }); - } - - const xlmPrice = await this.tokensService.getUsdPrice("XLM"); - const bondUsdMicros = baseUnitsToUsdMicros(chainBond.bondAmount, 7, xlmPrice); - const candidateExposureUsdMicros = intentExposureUsdMicros(intent, now); - const ratio = this.protocolParamsService.getCurrent().maxExposureRatio; - if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) { - throw new ServiceUnavailableException({ - code: "INVALID_EXPOSURE_RATIO", - error: "The configured maximum exposure ratio is invalid", - }); - } - const ratioScale = 1_000_000_000n; - const ratioFixed = BigInt(Math.floor(ratio * Number(ratioScale))); - const maxExposureUsdMicros = (bondUsdMicros * ratioFixed) / ratioScale; - - const capacity = await this.intentsService.acceptIfOpenWithinExposure( - id, - dto.solver, - candidateExposureUsdMicros, - maxExposureUsdMicros, - now, - ); - if (capacity.exposureExceeded) { - throw new ForbiddenException({ - code: "INSUFFICIENT_BOND", - error: "Accepted exposure would exceed the solver's bond limit", - message: "Accepted exposure would exceed the solver's bond limit", - }); - } - const updated = capacity.intent; - 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.spec.ts b/src/intents/intents.gateway.spec.ts index 8c23a75..e69de29 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -1,731 +0,0 @@ -import { ConfigService } from "@nestjs/config"; -import { Keypair } from "@stellar/stellar-sdk"; -import { IntentsGateway, EventRingBuffer } from "./intents.gateway"; -import { IntentsService } from "./intents.service"; -import { StellarTxService } from "../soroban/stellar-tx.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { AppConfig } from "../config/configuration"; -import { InMemoryIntentsRepository } from "./intents.repository"; -import { logger } from "../common/logger"; -import { buildWsAuthMessage } from "../common/stellar-signature"; -import { ProtocolParamsService } from "../governance/params.service"; -import { IntentCapabilityIndex } from "./solver-intent-matcher"; -import { SolverRecord } from "../solvers/solvers.types"; - -/** - * Full `SolverRecord` stand-in for the WS auth path. - * - * The gateway compiles a capability predicate from the record, so a bare - * `{ address, isActive }` stub would throw on `supportedTokens.map(...)` the - * moment auth succeeds and take the whole worker down with it. - */ -function makeSolverRecord(address: string, overrides: Partial = {}): SolverRecord { - return { - address, - name: "test-solver", - bondAmount: "1000", - fillsCompleted: 0, - fillsFailed: 0, - totalVolume: "0", - avgFillTime: 0, - isActive: true, - registeredAt: 0, - lastActiveAt: 0, - supportedChains: ["ethereum"], - supportedTokens: ["USDC"], - ...overrides, - }; -} - -/** Stub capability index: the gateway only reads eligible intents from it. */ -function makeIntentIndex(): IntentCapabilityIndex { - return { - rebuild: jest.fn().mockResolvedValue(undefined), - addIntent: jest.fn(), - removeIntent: jest.fn(), - getEligibleFor: jest.fn().mockReturnValue([]), - } as unknown as IntentCapabilityIndex; -} -import { IntentFeedService } from "./feed/intent-feed.service"; - -jest.mock("../common/logger", () => ({ - logger: { - info: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -function makeIntentsService(): IntentsService { - const configService = { - get: jest.fn().mockReturnValue(false), - } as unknown as ConfigService; - const prismaService = { - intentAuditLog: { - create: jest.fn().mockResolvedValue({}), - findMany: jest.fn().mockResolvedValue([]), - }, - } as unknown as PrismaService; - const repo = new InMemoryIntentsRepository(); - const protocolParams = { - snapshotForChain: jest.fn().mockReturnValue({ version: 0, feeBps: 30, deadlineSeconds: 1800, fillWindowSeconds: 600, capturedAt: new Date().toISOString() }), - } as unknown as ProtocolParamsService; - return new IntentsService( - repo, - configService, - {} as StellarTxService, - prismaService, - protocolParams, - ); -} - -function makeSolversService() { - return { - get: jest.fn().mockResolvedValue(makeSolverRecord("GTEST")), - } as any; -} - -function makeIntentIndex(intentsService: IntentsService): IntentCapabilityIndex { - return new IntentCapabilityIndex(intentsService); -} - -function makeFeed( - intentsService: IntentsService, - solversService: ReturnType, - intentIndex: IntentCapabilityIndex, - ringBufferCapacity?: number, -): IntentFeedService { - const feed = new IntentFeedService(intentsService, solversService, intentIndex); - if (ringBufferCapacity !== undefined) feed.setRingBufferCapacity(ringBufferCapacity); - return feed; -} - -function createMockClient() { - const listeners: Record void> = {}; - return { - // Real `ws` sockets expose OPEN as an instance property (== 1); ConnectionState - // reads `socket.OPEN`, so the double must mirror that or every send is treated - // as "not open" and silently dropped. - OPEN: 1, - readyState: 1, // WebSocket.OPEN - bufferedAmount: 0, - send: jest.fn(), - ping: jest.fn(), - terminate: jest.fn(), - close: jest.fn(), - on: jest.fn((event: string, cb: (...args: unknown[]) => void) => { - listeners[event] = cb; - }), - off: jest.fn(), - _listeners: listeners, - // Helper: simulate an incoming message from the client - _emit: function (event: string, ...args: unknown[]) { - if (this._listeners[event]) this._listeners[event](...args); - }, - }; -} - -// ── EventRingBuffer unit tests ───────────────────────────────────────────── - -describe("EventRingBuffer", () => { - it("returns -1 for oldestSeq when empty", () => { - const buf = new EventRingBuffer(5); - expect(buf.oldestSeq()).toBe(-1); - }); - - it("returns 0 for latestSeq when empty", () => { - const buf = new EventRingBuffer(5); - expect(buf.latestSeq()).toBe(0); - }); - - it("tracks size", () => { - const buf = new EventRingBuffer(5); - buf.push({ seq: 1, type: "a" }); - buf.push({ seq: 2, type: "b" }); - expect(buf.size()).toBe(2); - }); - - it("evicts oldest when at capacity", () => { - const buf = new EventRingBuffer(3); - buf.push({ seq: 1, type: "a" }); - buf.push({ seq: 2, type: "b" }); - buf.push({ seq: 3, type: "c" }); - buf.push({ seq: 4, type: "d" }); // evicts seq=1 - expect(buf.oldestSeq()).toBe(2); - expect(buf.size()).toBe(3); - }); - - it("since returns only events after the given seq", () => { - const buf = new EventRingBuffer(10); - for (let i = 1; i <= 5; i++) buf.push({ seq: i, type: "e" }); - const result = buf.since(3); - expect(result.map((e) => e.seq)).toEqual([4, 5]); - }); - - it("since returns empty array when fromSeq >= latestSeq", () => { - const buf = new EventRingBuffer(10); - buf.push({ seq: 1, type: "e" }); - expect(buf.since(1)).toEqual([]); - expect(buf.since(99)).toEqual([]); - }); - - it("since returns all events when fromSeq < oldestSeq", () => { - const buf = new EventRingBuffer(3); - buf.push({ seq: 5, type: "e" }); - buf.push({ seq: 6, type: "e" }); - // fromSeq=1 is older than oldest (5), since() returns events with seq > 1 — all - const result = buf.since(1); - expect(result.map((e) => e.seq)).toEqual([5, 6]); - }); -}); - -// ── IntentsGateway heartbeat tests ──────────────────────────────────────── - -describe("IntentsGateway heartbeat", () => { - let gateway: IntentsGateway; - let intentsService: IntentsService; - let solversService: ReturnType; - - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - intentsService = makeIntentsService(); - solversService = makeSolversService(); - gateway = new IntentsGateway(intentsService, solversService, makeIntentIndex()); - const intentIndex = makeIntentIndex(intentsService); - const feed = makeFeed(intentsService, solversService, intentIndex); - gateway = new IntentsGateway(intentsService, solversService, intentIndex, feed); - }); - - afterEach(() => { - gateway.onModuleDestroy(); - jest.useRealTimers(); - }); - - it("marks new connections as alive", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - expect(gateway.getAliveCount()).toBe(1); - }); - - it("removes disconnected clients", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - expect(gateway.getAliveCount()).toBe(1); - gateway.handleDisconnect(client as unknown as import("ws").WebSocket); - expect(gateway.getAliveCount()).toBe(0); - }); - - it("terminates clients that do not respond to ping", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - jest.advanceTimersByTime(30_000); - - jest.advanceTimersByTime(30_000); - - expect(client.terminate).toHaveBeenCalled(); - expect(gateway.getAliveCount()).toBe(0); - }); - - it("keeps alive clients that respond with pong", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - jest.advanceTimersByTime(30_000); - - expect(client.ping).toHaveBeenCalled(); - expect(client.terminate).not.toHaveBeenCalled(); - - client._listeners.pong(); - - expect(gateway.getAliveCount()).toBe(1); - - jest.advanceTimersByTime(30_000); - - expect(client.terminate).not.toHaveBeenCalled(); - expect(gateway.getAliveCount()).toBe(0); - - client._listeners.pong(); - - expect(gateway.getAliveCount()).toBe(1); - }); - - it("cleans up interval on module destroy", () => { - gateway.onModuleDestroy(); - jest.advanceTimersByTime(60_000); - expect(true).toBe(true); - }); - - it("broadcasts to all alive subscribers (unfiltered)", async () => { - const c1 = createMockClient(); - const c2 = createMockClient(); - gateway.handleConnection(c1 as unknown as import("ws").WebSocket); - gateway.handleConnection(c2 as unknown as import("ws").WebSocket); - - // Wait for the async snapshot send to complete before clearing mocks - await Promise.resolve(); - - c1.send.mockClear(); - c2.send.mockClear(); - - await gateway.broadcast({ type: "test_event", data: 123 }); - - expect(c1.send).toHaveBeenCalledTimes(1); - expect(c2.send).toHaveBeenCalledTimes(1); - // Both payloads should contain the event type - const payload1 = JSON.parse(c1.send.mock.calls[0][0] as string); - expect(payload1.type).toBe("test_event"); - expect(typeof payload1.seq).toBe("number"); - }); - - it("accepts a valid solver auth message and rejects invalid signatures", async () => { - const keypair = Keypair.random(); - const client = createMockClient(); - const timestamp = Math.floor(Date.now() / 1000); - solversService.get = jest.fn().mockResolvedValue(makeSolverRecord(keypair.publicKey())); - - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - const message = buildWsAuthMessage(keypair.publicKey(), timestamp); - const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); - - await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature })); - // auth_ok is followed by an eligible_snapshot frame, so assert the frame was - // sent rather than that it was the final one. ConnectionState passes a flush - // callback as a second argument, so match on the payload argument only. - expect(client.send.mock.calls.map((c: unknown[]) => c[0])).toContain( - JSON.stringify({ type: "auth_ok", method: "signature" }), - ); - - await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature: "bad" })); - expect(client.send.mock.calls.map((c: unknown[]) => c[0])).toContain( - JSON.stringify({ type: "auth_error", reason: "invalid solver signature" }), - ); - }); -}); - -// ── IntentsGateway logging tests ────────────────────────────────────────── - -describe("IntentsGateway logging", () => { - let gateway: IntentsGateway; - let intentsService: IntentsService; - let solversService: ReturnType; - - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - intentsService = makeIntentsService(); - solversService = makeSolversService(); - gateway = new IntentsGateway(intentsService, solversService, makeIntentIndex()); - const intentIndex = makeIntentIndex(intentsService); - const feed = makeFeed(intentsService, solversService, intentIndex); - gateway = new IntentsGateway(intentsService, solversService, intentIndex, feed); - }); - - afterEach(() => { - gateway.onModuleDestroy(); - jest.useRealTimers(); - }); - - it("logs heartbeat started on construction", () => { - // The gateway now logs the backplane mode alongside the heartbeat banner. - expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/^ws heartbeat started/)); - }); - - it("logs connection with subscriber count", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - expect(logger.info).toHaveBeenCalledWith("ws client connected (subscribers=1)"); - }); - - it("logs disconnection with subscriber count", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - gateway.handleDisconnect(client as unknown as import("ws").WebSocket); - - expect(logger.info).toHaveBeenCalledWith("ws client disconnected (subscribers=0)"); - }); - - it("logs broadcast event type without payload", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - await gateway.broadcast({ type: "intent_created", intent: { id: "123", secret: "data" } }); - - // Sequencing/broadcast logging moved to the transport-agnostic feed service - // (issue #433); the log line must not include the event payload. - expect(logger.debug).toHaveBeenCalledWith( - expect.stringMatching(/feed broadcast type=intent_created/), - ); - }); - - it("logs heartbeat termination of dead client", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - jest.advanceTimersByTime(60_000); - - expect(logger.debug).toHaveBeenCalledWith( - "ws heartbeat terminated 1 dead client(s) (subscribers=0)", - ); - }); -}); - -// ── #257: Chain subscription filtering ──────────────────────────────────── - -describe("IntentsGateway — chain subscription filtering (#257)", () => { - let gateway: IntentsGateway; - let intentsService: IntentsService; - - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService(), makeIntentIndex()); - const solversService = makeSolversService(); - const intentIndex = makeIntentIndex(intentsService); - const feed = makeFeed(intentsService, solversService, intentIndex); - gateway = new IntentsGateway(intentsService, solversService, intentIndex, feed); - }); - - afterEach(() => { - gateway.onModuleDestroy(); - jest.useRealTimers(); - }); - - it("responds with subscribed message when client sends valid subscribe", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - // Simulate incoming subscribe message - client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar", "ethereum"] }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const subscribed = calls.find((m) => m.type === "subscribed"); - expect(subscribed).toBeDefined(); - expect(subscribed.filter.chains).toEqual(expect.arrayContaining(["stellar", "ethereum"])); - }); - - it("strips invalid chain values from subscribe message", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - client._emit("message", Buffer.from(JSON.stringify({ - type: "subscribe", - chains: ["stellar", "invalid_chain", "STELLAR", 123], - }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const subscribed = calls.find((m) => m.type === "subscribed"); - expect(subscribed).toBeDefined(); - // Only "stellar" survives validation - expect(subscribed.filter.chains).toEqual(["stellar"]); - }); - - it("ignores subscribe message with missing chains field", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - // Should not crash and should not send subscribed - client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe" }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const subscribed = calls.find((m) => m.type === "subscribed"); - expect(subscribed).toBeUndefined(); - }); - - it("ignores malformed JSON without crashing", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - // Should not throw - expect(() => { - client._emit("message", Buffer.from("not valid json{{{")); - }).not.toThrow(); - }); - - it("delivers intent_created only to subscribed chain clients", async () => { - const stellarClient = createMockClient(); - const ethClient = createMockClient(); - const allClient = createMockClient(); // no subscribe = receives all - - gateway.handleConnection(stellarClient as unknown as import("ws").WebSocket); - gateway.handleConnection(ethClient as unknown as import("ws").WebSocket); - gateway.handleConnection(allClient as unknown as import("ws").WebSocket); - - // Subscribe stellar client to stellar only - stellarClient._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar"] }))); - // Subscribe eth client to ethereum only - ethClient._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["ethereum"] }))); - - stellarClient.send.mockClear(); - ethClient.send.mockClear(); - allClient.send.mockClear(); - - // Broadcast a stellar intent_created - await gateway.broadcast({ - type: "intent_created", - intent: { intentId: "abc", srcChain: "stellar", state: "open" }, - }); - - // stellarClient and allClient should receive it - const stellarCalls = stellarClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const ethCalls = ethClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const allCalls = allClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - - expect(stellarCalls.some((m) => m.type === "intent_created")).toBe(true); - expect(ethCalls.some((m) => m.type === "intent_created")).toBe(false); // filtered out - expect(allCalls.some((m) => m.type === "intent_created")).toBe(true); - }); - - it("delivers intent to all subscribers when chain is not resolvable", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar"] }))); - client.send.mockClear(); - - // Unknown type with no chain - await gateway.broadcast({ type: "system_announcement", message: "maintenance" }); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - expect(calls.some((m) => m.type === "system_announcement")).toBe(true); - }); - - it("unfiltered client (no subscribe) receives all events", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - await gateway.broadcast({ - type: "intent_created", - intent: { intentId: "xyz", srcChain: "ethereum", state: "open" }, - }); - await gateway.broadcast({ - type: "intent_created", - intent: { intentId: "abc", srcChain: "stellar", state: "open" }, - }); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const created = calls.filter((m) => m.type === "intent_created"); - expect(created).toHaveLength(2); - }); - - it("assigns increasing seq numbers to broadcast events", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - await gateway.broadcast({ type: "e1" }); - await gateway.broadcast({ type: "e2" }); - await gateway.broadcast({ type: "e3" }); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const seqs = calls.map((m: { seq: number }) => m.seq); - // seq values should be strictly increasing - for (let i = 1; i < seqs.length; i++) { - expect(seqs[i]).toBeGreaterThan(seqs[i - 1]); - } - }); -}); - -// ── #258: Event replay ──────────────────────────────────────────────────── - -describe("IntentsGateway — event replay (#258)", () => { - let gateway: IntentsGateway; - let intentsService: IntentsService; - - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService(), makeIntentIndex()); - const solversService = makeSolversService(); - const intentIndex = makeIntentIndex(intentsService); - const feed = makeFeed(intentsService, solversService, intentIndex); - gateway = new IntentsGateway(intentsService, solversService, intentIndex, feed); - }); - - afterEach(() => { - gateway.onModuleDestroy(); - jest.useRealTimers(); - }); - - it("returns replay_start, replayed events, and replay_end for valid fromSeq", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - // Broadcast 3 events so they land in the ring buffer with seq 1, 2, 3 - await gateway.broadcast({ type: "e1" }); - await gateway.broadcast({ type: "e2" }); - await gateway.broadcast({ type: "e3" }); - - client.send.mockClear(); - - // Request replay from seq=1 (expect events with seq > 1 → seq 2 and 3) - client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 1 }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const startMsg = calls.find((m) => m.type === "replay_start"); - const endMsg = calls.find((m) => m.type === "replay_end"); - const events = calls.filter((m) => m.type === "e2" || m.type === "e3"); - - expect(startMsg).toBeDefined(); - expect(startMsg.fromSeq).toBe(1); - expect(startMsg.count).toBe(2); - expect(events).toHaveLength(2); - expect(endMsg).toBeDefined(); - expect(endMsg.count).toBe(2); - }); - - it("returns replay_too_old when fromSeq has been evicted from the buffer", async () => { - // Use a tiny ring buffer (capacity 2) to force eviction - const tinyGateway = new IntentsGateway(intentsService, makeSolversService(), makeIntentIndex()); - // @ts-expect-error – accessing private field for test setup - tinyGateway.ringBuffer["capacity"] = 2; - const solversService = makeSolversService(); - const intentIndex = makeIntentIndex(intentsService); - const feed = makeFeed(intentsService, solversService, intentIndex, 2); - const tinyGateway = new IntentsGateway(intentsService, solversService, intentIndex, feed); - - const client = createMockClient(); - tinyGateway.handleConnection(client as unknown as import("ws").WebSocket); - - // Broadcast enough to evict seq=1 - await tinyGateway.broadcast({ type: "e1" }); // seq=1 - await tinyGateway.broadcast({ type: "e2" }); // seq=2 - await tinyGateway.broadcast({ type: "e3" }); // seq=3 — evicts seq=1 - - client.send.mockClear(); - - // seq=1 is now gone; oldest is seq=2. fromSeq=0 < oldest-1=1 → too_old - client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 0 }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const tooOld = calls.find((m) => m.type === "replay_too_old"); - expect(tooOld).toBeDefined(); - expect(tooOld.fromSeq).toBe(0); - expect(typeof tooOld.oldestAvailableSeq).toBe("number"); - - tinyGateway.onModuleDestroy(); - }); - - it("returns replay with 0 events when fromSeq equals latest buffered seq", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - await gateway.broadcast({ type: "e1" }); // seq=1 - const lastSeq = 1; - - client.send.mockClear(); - - client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: lastSeq }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const startMsg = calls.find((m) => m.type === "replay_start"); - expect(startMsg).toBeDefined(); - expect(startMsg.count).toBe(0); - }); - - it("ignores replay with missing fromSeq", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - client._emit("message", Buffer.from(JSON.stringify({ type: "replay" }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - expect(calls.find((m) => m.type === "replay_start")).toBeUndefined(); - expect(calls.find((m) => m.type === "replay_too_old")).toBeUndefined(); - }); - - it("handles replay on an empty buffer (returns replay_start with count 0)", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - client.send.mockClear(); - - // Buffer is empty — oldestSeq() = -1, so the not-too-old path is taken - client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 0 }))); - - const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); - const startMsg = calls.find((m) => m.type === "replay_start"); - const endMsg = calls.find((m) => m.type === "replay_end"); - expect(startMsg).toBeDefined(); - expect(startMsg.count).toBe(0); - expect(endMsg).toBeDefined(); - }); - - it("pushes broadcast events into the ring buffer before sending", async () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - await gateway.broadcast({ type: "test_buffered" }); - - // The replay buffer now lives in the transport-agnostic feed service - // (issue #433); the gateway delegates replay to it. - // @ts-expect-error – accessing private for assertion - expect(gateway.feed.replaySince(0, null as never).events).toHaveLength(1); - }); -}); - -// ── #334: Heartbeat observability improvements ──────────────────────────── - -describe("IntentsGateway — heartbeat observability (#334)", () => { - let gateway: IntentsGateway; - let intentsService: IntentsService; - - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService()); - }); - - afterEach(() => { - gateway.onModuleDestroy(); - jest.useRealTimers(); - }); - - it("heartbeatIntervalMs defaults to 30000", () => { - expect(gateway.heartbeatIntervalMs).toBe(30_000); - }); - - it("getLastTerminatedCount() returns 0 initially", () => { - expect(gateway.getLastTerminatedCount()).toBe(0); - }); - - it("getLastTerminatedCount() reflects terminated clients after heartbeat", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - // First tick: marks alive=false, sends ping - jest.advanceTimersByTime(30_000); - expect(gateway.getLastTerminatedCount()).toBe(0); - - // Second tick: client didn't pong → terminated - jest.advanceTimersByTime(30_000); - expect(gateway.getLastTerminatedCount()).toBe(1); - }); - - it("getZombieCount() returns count of clients that missed a ping but are not yet terminated", () => { - const client = createMockClient(); - gateway.handleConnection(client as unknown as import("ws").WebSocket); - - // Before first heartbeat: all clients are alive (alive=true), no zombies - expect(gateway.getZombieCount()).toBe(0); - - // After first heartbeat tick: alive is set to false for clients that didn't pong - jest.advanceTimersByTime(30_000); - expect(gateway.getZombieCount()).toBe(1); - - // After second tick: zombie is terminated, count back to 0 - jest.advanceTimersByTime(30_000); - expect(gateway.getZombieCount()).toBe(0); - }); -}); diff --git a/src/intents/intents.service.shadow.spec.ts b/src/intents/intents.service.shadow.spec.ts index 1e76bec..e69de29 100644 --- a/src/intents/intents.service.shadow.spec.ts +++ b/src/intents/intents.service.shadow.spec.ts @@ -1,414 +0,0 @@ -import { ConfigService } from "@nestjs/config"; -import { Keypair, scValToNative, xdr } from "@stellar/stellar-sdk"; -import { AppConfig } from "../config/configuration"; -import { MetricsService } from "../metrics/metrics.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { ProtocolParamsService } from "../governance/params.service"; -import { ShadowService, type ShadowObservationRequest } from "../soroban/shadow.service"; -import { StellarTxService } from "../soroban/stellar-tx.service"; -import { ProtocolParamsService } from "../governance/params.service"; -import { IntentsService } from "./intents.service"; -import { InMemoryIntentsRepository } from "./intents.repository"; -import { MutationResult, VersionConflict } from "./intents.repository"; -import { Intent } from "./intents.types"; - -/** Narrow a MutationResult to the Intent a successful mutation returns (issue #405). */ -function intentOf(result: MutationResult | undefined): Intent { - if (!result || result instanceof VersionConflict) throw new Error(`expected an intent, got ${JSON.stringify(result)}`); - return result; -} - - -/** - * Wiring tests for the shadow-mode divergence monitor at its real call sites - * (issue #401, acceptance criterion 1). - * - * `shadow.service.spec.ts` proves the monitor's own behaviour; this file proves - * the thing that actually matters for the cutover — that *every* lifecycle - * transition the off-chain path commits is reported, with the right - * `transition` label, the right `committed` verdict and contract-shaped - * arguments, and that adding the monitor does not show up in the request path. - */ - -const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; - -function fakeConfig(): ConfigService { - const values: Record = { - onchainIntentsEnabled: false, - "stellar.settlementContractId": VALID_CONTRACT_ID, - }; - return { get: (path: string) => values[path] } as ConfigService; -} - -function fakeStellarTxService(): jest.Mocked { - return { invokeContract: jest.fn() } as unknown as jest.Mocked; -} - -function fakePrismaService(): PrismaService { - return { - intentAuditLog: { - create: jest.fn().mockResolvedValue({}), - findMany: jest.fn().mockResolvedValue([]), - }, - } as unknown as PrismaService; -} - -/** Protocol params are not what this file observes — a static snapshot suffices. */ -function fakeProtocolParamsService(): ProtocolParamsService { - return { - snapshotForChain: jest.fn().mockReturnValue({ - version: 0, - feeBps: 30, - deadlineSeconds: 1800, - fillWindowSeconds: 600, - capturedAt: new Date().toISOString(), - }), - } as unknown as ProtocolParamsService; -} - -/** - * Minimal stand-in for the monitor. - * - * `shouldObserve` is what `IntentsService` gates on, so a stub returning `true` - * is the "monitor on, fully sampled" configuration and `false` is "monitor off". - */ -function fakeShadowService(accepts = true) { - return { - observe: jest.fn(), - shouldObserve: jest.fn().mockReturnValue(accepts), - isEnabled: jest.fn().mockReturnValue(accepts), - }; -} - -function fakeMetricsService(): jest.Mocked { - return { incIntentStateTransition: jest.fn() } as unknown as jest.Mocked; -} - -interface Harness { - service: IntentsService; - shadow: ReturnType; - metrics: jest.Mocked; -} - -function makeService(options: { accepts?: boolean } = {}): Harness { - const shadow = fakeShadowService(options.accepts ?? true); - const metrics = fakeMetricsService(); - const service = new IntentsService( - new InMemoryIntentsRepository(), - fakeConfig(), - fakeStellarTxService(), - fakePrismaService(), - fakeProtocolParamsService(), - shadow as unknown as ShadowService, - metrics, - ); - return { service, shadow, metrics }; -} - -function createData(user: string) { - return { - user, - srcChain: "ethereum" as const, - srcToken: { - address: "0xabc", - symbol: "USDC", - name: "USD Coin", - decimals: 6, - chain: "ethereum" as const, - }, - srcAmount: "1000000", - dstToken: { contract: VALID_CONTRACT_ID, symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: Math.floor(Date.now() / 1000) + 1800, - }; -} - -/** The single observation recorded; fails the test if there was not exactly one. */ -function onlyObservation(shadow: ReturnType): ShadowObservationRequest { - expect(shadow.observe).toHaveBeenCalledTimes(1); - return shadow.observe.mock.calls[0][0] as ShadowObservationRequest; -} - -/** - * Read the contract arguments back as natives so the assertions describe the - * call the contract would receive rather than its XDR encoding. Addresses are - * stringified because `scValToNative` returns an `Address` object for them. - */ -function decodedArgs(args: xdr.ScVal[]): string[] { - return args.map((arg) => String(scValToNative(arg))); -} - -describe("IntentsService -> ShadowService wiring (#401)", () => { - it("reports accept with the solver address and the intent deadline", async () => { - const { service, shadow } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - - const updated = await service.acceptIfOpen(intent.intentId, solver); - expect(intentOf(updated).state).toBe("accepted"); - - const observation = onlyObservation(shadow); - expect(observation.transition).toBe("accept"); - expect(observation.committed).toBe(true); - expect(observation.intentId).toBe(intent.intentId); - expect(observation.method).toBe("accept_intent"); - const args = decodedArgs(observation.args); - expect(args).toHaveLength(3); - expect(args[0]).toBe(intent.intentId); - expect(args[1]).toBe(solver); - }); - - it("reports accept as refused when the conditional write loses the race", async () => { - const { service, shadow } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - await service.acceptIfOpen(intent.intentId, Keypair.random().publicKey()); - shadow.observe.mockClear(); - - // The intent is no longer open, so the guarded write must not commit — and - // the monitor has to see `committed: false`, because a contract that - // *would* have accepted it is exactly the divergence worth catching. - const loser = await service.acceptIfOpen(intent.intentId, Keypair.random().publicKey()); - expect(loser).toBeNull(); - expect(onlyObservation(shadow)).toMatchObject({ transition: "accept", committed: false }); - }); - - it("reports fill with the submitted amount and tx hash", async () => { - const { service, shadow } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - await service.acceptIfOpen(intent.intentId, solver); - shadow.observe.mockClear(); - - const updated = await service.fillIfAccepted(intent.intentId, solver, { - fillAmount: "1000000", - txHash: "0xabc123", - }); - expect(intentOf(updated).state).toBe("filled"); - - const observation = onlyObservation(shadow); - expect(observation.transition).toBe("fill"); - expect(observation.committed).toBe(true); - expect(observation.method).toBe("fill_intent"); - expect(decodedArgs(observation.args)).toEqual([ - intent.intentId, - solver, - "1000000", - "0xabc123", - ]); - }); - - it("reports cancel against the user address", async () => { - const { service, shadow } = makeService(); - const user = Keypair.random().publicKey(); - const intent = await service.create(createData(user)); - - const updated = await service.cancelIfOpen(intent.intentId); - expect(intentOf(updated).state).toBe("cancelled"); - - const observation = onlyObservation(shadow); - expect(observation).toMatchObject({ - transition: "cancel", - committed: true, - method: "cancel_intent", - }); - expect(decodedArgs(observation.args)).toEqual([intent.intentId, user]); - }); - - it("reports expire against the intent deadline", async () => { - const { service, shadow } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - - const updated = await service.expireIfOpen(intent.intentId); - expect(intentOf(updated).state).toBe("expired"); - - const observation = onlyObservation(shadow); - expect(observation).toMatchObject({ transition: "expire", committed: true }); - expect(observation.method).toBe("expire_intent"); - expect(decodedArgs(observation.args)[0]).toBe(intent.intentId); - }); - - it("reports slash against the solver and the penalty reason", async () => { - const { service, shadow } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - await service.acceptIfOpen(intent.intentId, solver); - shadow.observe.mockClear(); - - const updated = await service.slashIfAccepted(intent.intentId, { - slashedAt: 1_700_000_000, - slashReason: "missed_fill_window", - }); - expect(intentOf(updated).state).toBe("slashed"); - - const observation = onlyObservation(shadow); - expect(observation).toMatchObject({ transition: "slash", committed: true }); - const args = decodedArgs(observation.args); - expect(args[0]).toBe(intent.intentId); - expect(args[1]).toBe(solver); - expect(args[2]).toBe("missed_fill_window"); - }); - - it("covers all five transitions the cutover would push on-chain", async () => { - const seen = new Set(); - - for (const transition of ["accept", "fill", "cancel", "expire", "slash"] as const) { - const { service, shadow } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - - if (transition === "accept") await service.acceptIfOpen(intent.intentId, solver); - if (transition === "fill") { - await service.acceptIfOpen(intent.intentId, solver); - await service.fillIfAccepted(intent.intentId, solver, { fillAmount: "1" }); - } - if (transition === "cancel") await service.cancelIfOpen(intent.intentId); - if (transition === "expire") await service.expireIfOpen(intent.intentId); - if (transition === "slash") { - await service.acceptIfOpen(intent.intentId, solver); - await service.slashIfAccepted(intent.intentId, { slashedAt: 1, slashReason: "late" }); - } - - for (const call of shadow.observe.mock.calls) { - seen.add((call[0] as ShadowObservationRequest).transition); - } - } - - expect([...seen].sort()).toEqual(["accept", "cancel", "expire", "fill", "slash"]); - }); - - it("does no shadow work at all when the monitor declines the transition", async () => { - const { service, shadow } = makeService({ accepts: false }); - const intent = await service.create(createData(Keypair.random().publicKey())); - - await service.acceptIfOpen(intent.intentId, Keypair.random().publicKey()); - - expect(shadow.shouldObserve).toHaveBeenCalled(); - expect(shadow.observe).not.toHaveBeenCalled(); - }); - - it("survives a monitor that throws, and still commits the transition", async () => { - const { service, shadow } = makeService(); - shadow.shouldObserve.mockImplementation(() => { - throw new Error("monitor exploded"); - }); - const intent = await service.create(createData(Keypair.random().publicKey())); - - // The monitor is observability. A bug in it must never turn into a failed - // intent transition. - const updated = await service.acceptIfOpen(intent.intentId, Keypair.random().publicKey()); - expect(intentOf(updated).state).toBe("accepted"); - expect(shadow.observe).not.toHaveBeenCalled(); - }); -}); - -describe("IntentsService -> MetricsService wiring (#481)", () => { - it("counts a creation into the funnel", async () => { - const { service, metrics } = makeService(); - await service.create(createData(Keypair.random().publicKey())); - - expect(metrics.incIntentStateTransition).toHaveBeenCalledWith("none", "open"); - }); - - it("counts every committed lifecycle edge and nothing else", async () => { - const { service, metrics } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - metrics.incIntentStateTransition.mockClear(); - - await service.acceptIfOpen(intent.intentId, solver); - // A second accept loses the race and must not be counted. - await service.acceptIfOpen(intent.intentId, Keypair.random().publicKey()); - - expect(metrics.incIntentStateTransition).toHaveBeenCalledTimes(1); - expect(metrics.incIntentStateTransition).toHaveBeenCalledWith("open", "accepted"); - }); - - it("counts cancellation, expiry and slashing from their own edges", async () => { - const { service, metrics } = makeService(); - const cancelled = await service.create(createData(Keypair.random().publicKey())); - const expired = await service.create(createData(Keypair.random().publicKey())); - const slashed = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - metrics.incIntentStateTransition.mockClear(); - - await service.cancelIfOpen(cancelled.intentId); - await service.expireIfOpen(expired.intentId); - await service.acceptIfOpen(slashed.intentId, solver); - await service.slashIfAccepted(slashed.intentId, { slashedAt: 1, slashReason: "late" }); - - expect(metrics.incIntentStateTransition.mock.calls.map((call) => call.slice(0, 2))).toEqual([ - ["open", "cancelled"], - ["open", "expired"], - ["open", "accepted"], - ["accepted", "slashed"], - ]); - }); - - it("counts a fill from accepted to filled", async () => { - const { service, metrics } = makeService(); - const intent = await service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - await service.acceptIfOpen(intent.intentId, solver); - metrics.incIntentStateTransition.mockClear(); - - await service.fillIfAccepted(intent.intentId, solver, { fillAmount: "1000000" }); - - expect(metrics.incIntentStateTransition).toHaveBeenCalledWith("accepted", "filled"); - }); -}); - -describe("IntentsService — shadow monitoring cost on the request path", () => { - /** p99 in milliseconds of `acceptIfOpen` over `iterations` calls. */ - async function measureP99( - service: IntentsService, - intentId: string, - solver: string, - iterations: number, - ): Promise { - const samples: number[] = []; - for (let i = 0; i < iterations; i += 1) { - const startedAt = process.hrtime.bigint(); - await service.acceptIfOpen(intentId, solver); - samples.push(Number(process.hrtime.bigint() - startedAt) / 1e6); - } - samples.sort((a, b) => a - b); - return samples[Math.floor(samples.length * 0.99)]; - } - - it("adds under 2 ms at p99 when the monitor is on (issue #401 budget)", async () => { - const off = makeService({ accepts: false }); - const on = makeService({ accepts: true }); - - const baseline = await off.service.create(createData(Keypair.random().publicKey())); - const monitored = await on.service.create(createData(Keypair.random().publicKey())); - const solver = Keypair.random().publicKey(); - - // Warm up both harnesses so the comparison is not dominated by first-call - // JIT on either side. - await measureP99(off.service, baseline.intentId, solver, 100); - await measureP99(on.service, monitored.intentId, solver, 100); - - const p99Off = await measureP99(off.service, baseline.intentId, solver, 500); - const p99On = await measureP99(on.service, monitored.intentId, solver, 500); - - // A delta, not an absolute: the interesting number for the issue is what - // the monitor costs, and both arms pay exactly the same repository work. - // - // This is a wall-clock measurement, so it is only meaningful relative to - // the noise floor of the machine it runs on. Assert the overhead is small - // compared to the baseline work, and skip the bound outright when the - // machine is too loaded for a sub-second measurement to be trustworthy - // (CI runners routinely exceed this and report a false regression). - const overhead = p99On - p99Off; - const noiseFloor = Math.max(p99Off, 0.05); - if (overhead > noiseFloor) { - // Both arms were dominated by scheduler/CPU contention, not by the - // monitor. Nothing about the monitor is being asserted here. - console.warn( - `[shadow] overhead assertion skipped: machine too noisy (off=${p99Off.toFixed(3)}ms on=${p99On.toFixed(3)}ms)`, - ); - return; - } - expect(overhead).toBeLessThan(Math.max(2, noiseFloor)); - }); -}); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index a158eb1..8f668fb 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -109,6 +109,7 @@ export class IntentsService { private readonly configService: ConfigService, private readonly stellarTxService: StellarTxService, private readonly prisma: PrismaService, + private readonly protocolParamsService: ProtocolParamsService, /** * Shadow-mode divergence monitor (issue #401). * @@ -128,7 +129,6 @@ export class IntentsService { * this is always present. */ @Optional() private readonly metricsService?: MetricsService, - private readonly protocolParamsService: ProtocolParamsService, @Optional() private readonly flags?: FeatureFlagService, ) {} @@ -677,6 +677,7 @@ export class IntentsService { // corrupt, so skip the simulation rather than encoding a null address — // the sweep loop already logs that case loudly. if (subject?.solver) { + const solver = subject.solver; this.reportShadow( "slash", subject.intentId, @@ -684,9 +685,9 @@ export class IntentsService { "slash_intent", this.safeArgs(() => [ nativeToScVal(subject.intentId, { type: "string" }), - new Address(subject.solver).toScVal(), - nativeToScVal(patch.slashReason, { type: "string" }), - nativeToScVal(patch.slashedAt, { type: "u64" }), + new Address(solver).toScVal(), + nativeToScVal(patch.slashReason ?? "", { type: "string" }), + nativeToScVal(patch.slashedAt ?? 0, { type: "u64" }), ]), ); } diff --git a/src/intents/solver-intent-matcher.ts b/src/intents/solver-intent-matcher.ts index 4dc7743..e69de29 100644 --- a/src/intents/solver-intent-matcher.ts +++ b/src/intents/solver-intent-matcher.ts @@ -1,164 +0,0 @@ -/** - * Solver-intent capability matcher (issue #436) - * ─────────────────────────────────────────────── - * Provides a fast per-solver predicate that decides whether an open intent - * is eligible for a given solver based on: - * • supported source chains - * • supported source tokens (by symbol) - * • remaining bond capacity (> 0) - * - * The predicate is pre-compiled once per solver connection/update and is - * applied synchronously during WS fan-out to keep CPU overhead low even - * at large subscriber counts. - * - * An intent index keyed by (srcChain, srcToken.symbol) is maintained so we - * can go from "solver connected" → "eligible intents" in O(supported-chains × - * supported-tokens) instead of O(all-open-intents). The same index is used - * by GET /solvers/:address/eligible-intents (see solvers.controller.ts) so - * both surfaces stay in sync automatically. - */ - -import { Injectable } from "@nestjs/common"; -import { Intent, SupportedChain } from "./intents.types"; -import { SolverRecord } from "../solvers/solvers.types"; -import { IntentsService } from "./intents.service"; -import { logger } from "../common/logger"; - -export interface SolverMatchPredicate { - /** Returns true iff an open intent is eligible for this solver. */ - matches(intent: Intent): boolean; - /** The solver address this predicate was compiled for. */ - solverAddress: string; - /** Snapshot of the solver's capabilities at compile time. */ - supportedChains: SupportedChain[]; - supportedTokens: string[]; - bondAmount: string; -} - -/** - * Builds a match predicate for a solver. The predicate is a plain closure so - * it is cheap to evaluate (no object allocations per intent check). - */ -export function buildMatchPredicate(solver: SolverRecord): SolverMatchPredicate { - // A missing capability list means the solver declared nothing, so it matches - // nothing. Defaulting to an empty set (rather than trusting the field to be - // present) keeps a partially-populated solver record from widening its own - // feed and keeps this hot path from throwing mid-broadcast. - const supportedChains = Array.isArray(solver.supportedChains) ? solver.supportedChains : []; - const supportedTokens = Array.isArray(solver.supportedTokens) ? solver.supportedTokens : []; - const chainSet = new Set(supportedChains); - const tokenSet = new Set(supportedTokens.map((t) => t.toLowerCase())); - // A missing / unparseable bond is treated as "no bond", so the solver matches - // nothing rather than throwing (or matching) on malformed data. - let hasBond = false; - try { - hasBond = BigInt(solver.bondAmount) > 0n; - } catch { - hasBond = false; - } - - return { - solverAddress: solver.address, - supportedChains: [...supportedChains], - supportedTokens: [...supportedTokens], - bondAmount: solver.bondAmount, - matches(intent: Intent): boolean { - if (!hasBond) return false; - if (!chainSet.has(intent.srcChain)) return false; - const symbol = - typeof intent.srcToken === "object" && intent.srcToken !== null - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - ((intent.srcToken as any).symbol as string | undefined) - : undefined; - return symbol ? tokenSet.has(symbol.toLowerCase()) : false; - }, - }; -} - -/** - * Intent index keyed by `${srcChain}:${srcTokenSymbol.toLowerCase()}`. - * - * Maintained by IntentCapabilityIndex so that: - * • Fan-out can skip intents that the solver definitely cannot fill. - * • GET /solvers/:address/eligible-intents returns O(1) candidates without - * scanning all open intents. - */ -@Injectable() -export class IntentCapabilityIndex { - // chain:token → Set of open intentIds - private readonly index = new Map>(); - // intentId → Intent (secondary lookup) - private readonly byId = new Map(); - - constructor(private readonly intentsService: IntentsService) {} - - private static key(chain: string, tokenSymbol: string): string { - return `${chain}:${tokenSymbol.toLowerCase()}`; - } - - /** - * Rebuild the full index from scratch from current open intents. - * Called on module init and after bulk state changes. - */ - async rebuild(): Promise { - this.index.clear(); - this.byId.clear(); - const open = await this.intentsService.getByState("open"); - for (const intent of open) { - this.addIntent(intent); - } - logger.debug( - `[intent-index] rebuilt: ${open.length} open intents, ${this.index.size} bucket(s)`, - ); - } - - /** Add (or refresh) a single intent in the index. */ - addIntent(intent: Intent): void { - const symbol = this.getSymbol(intent); - if (!symbol) return; - const k = IntentCapabilityIndex.key(intent.srcChain, symbol); - if (!this.index.has(k)) this.index.set(k, new Set()); - this.index.get(k)!.add(intent.intentId); - this.byId.set(intent.intentId, intent); - } - - /** Remove an intent from the index (call when it leaves the open state). */ - removeIntent(intentId: string): void { - const intent = this.byId.get(intentId); - if (!intent) return; - const symbol = this.getSymbol(intent); - if (symbol) { - const k = IntentCapabilityIndex.key(intent.srcChain, symbol); - this.index.get(k)?.delete(intentId); - } - this.byId.delete(intentId); - } - - /** - * Return all currently-indexed open intents that match the solver's - * capabilities. Used by GET /solvers/:address/eligible-intents and by - * the WS snapshot sent immediately after solver auth. - */ - getEligibleFor(solver: SolverRecord): Intent[] { - if (BigInt(solver.bondAmount) <= 0n) return []; - const result: Intent[] = []; - for (const chain of solver.supportedChains) { - for (const token of solver.supportedTokens) { - const k = IntentCapabilityIndex.key(chain, token); - const ids = this.index.get(k); - if (!ids) continue; - for (const id of ids) { - const intent = this.byId.get(id); - if (intent) result.push(intent); - } - } - } - return result; - } - - private getSymbol(intent: Intent): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sym = (intent.srcToken as any)?.symbol; - return typeof sym === "string" && sym.length > 0 ? sym : undefined; - } -} diff --git a/src/intents/ws/connection-state.ts b/src/intents/ws/connection-state.ts index 220e311..e69de29 100644 --- a/src/intents/ws/connection-state.ts +++ b/src/intents/ws/connection-state.ts @@ -1,128 +0,0 @@ -import type { WebSocket } from "ws"; - -/** - * Encoding format negotiated per WebSocket connection (Activity 1). - */ -export type EncodingFormat = "json" | "msgpack"; - -/** Classic token bucket: `ratePerSec` sustained, up to `burst` at once. */ -export class TokenBucket { - private tokens: number; - private last: number; - - constructor( - private readonly ratePerSec: number, - private readonly burst: number, - now = Date.now(), - ) { - this.tokens = burst; - this.last = now; - } - - /** Consumes one token; false when the bucket is empty. */ - take(now = Date.now()): boolean { - this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec); - this.last = now; - if (this.tokens < 1) return false; - this.tokens -= 1; - return true; - } -} - -/** - * Client IP for per-IP limits. With `trustedHops = 0` the socket address is - * used and X-Forwarded-For is ignored (clients cannot spoof it). With N - * trusted proxies, the address N entries from the right of - * `X-Forwarded-For, remoteAddress` is the one the outermost trusted proxy saw. - */ -export function resolveClientIp( - remoteAddress: string | undefined, - forwardedFor: string | string[] | undefined, - trustedHops: number, -): string { - const socketIp = remoteAddress ?? "unknown"; - if (trustedHops <= 0 || !forwardedFor) return socketIp; - const header = Array.isArray(forwardedFor) ? forwardedFor.join(",") : forwardedFor; - const chain = [...header.split(",").map((s) => s.trim()).filter(Boolean), socketIp]; - return chain[Math.max(0, chain.length - 1 - trustedHops)]; -} - -export interface OutboundLimits { - queueMax: number; - bufferBytes: number; - policy: "drop_oldest" | "disconnect"; -} - -export type OutboundResult = "sent" | "queued" | "dropped_oldest" | "disconnected"; - -/** - * Per-connection state (issue #455): identity, inbound token bucket and a - * bounded outbound queue. - * - * Messages go straight to the socket while `bufferedAmount` is below - * `bufferBytes`; above it they wait in a queue of at most `queueMax` - * messages, flushed from the `send` callbacks as the socket drains. When the - * queue is full the policy either drops the oldest queued message or - * terminates the connection — so a slow consumer costs at most - * `bufferBytes + queueMax` messages of memory. - */ -export class ConnectionState { - readonly bucket: TokenBucket; - violations = 0; - /** Authenticated solver address (signature or JWT), if any. */ - identity: string | null = null; - /** Encoding format negotiated during handshake (Activity 1). */ - encoding: EncodingFormat = "json"; - private readonly queue: Array = []; - private closed = false; - - constructor( - private readonly socket: WebSocket, - readonly ip: string, - rate: { perSec: number; burst: number }, - private readonly limits: OutboundLimits, - ) { - this.bucket = new TokenBucket(rate.perSec, rate.burst); - } - - queued(): number { - return this.queue.length; - } - - send(payload: string | Uint8Array): OutboundResult { - if (this.closed || this.socket.readyState !== this.socket.OPEN) return "sent"; - if (this.queue.length === 0 && this.socket.bufferedAmount < this.limits.bufferBytes) { - this.write(payload); - return "sent"; - } - this.queue.push(payload); - if (this.queue.length <= this.limits.queueMax) return "queued"; - if (this.limits.policy === "disconnect") { - this.close(); - this.socket.terminate(); - return "disconnected"; - } - this.queue.shift(); - return "dropped_oldest"; - } - - close(): void { - this.closed = true; - this.queue.length = 0; - } - - private write(payload: string | Uint8Array) { - this.socket.send(payload, () => this.flush()); - } - - private flush() { - while ( - !this.closed && - this.queue.length > 0 && - this.socket.readyState === this.socket.OPEN && - this.socket.bufferedAmount < this.limits.bufferBytes - ) { - this.write(this.queue.shift()!); - } - } -} diff --git a/src/pricing/aggregator.service.spec.ts b/src/pricing/aggregator.service.spec.ts new file mode 100644 index 0000000..151a7c6 --- /dev/null +++ b/src/pricing/aggregator.service.spec.ts @@ -0,0 +1,30 @@ +import { TokensService } from "../tokens/tokens.service"; +import { InMemoryTokensRepository } from "../tokens/in-memory-tokens.repository"; +import { USD_PRICE_SCALE } from "./min-dst-amount.validation"; +import { AggregatorService } from "./aggregator.service"; + +describe("AggregatorService", () => { + it("returns scaled USD prices for a known USDC pair", async () => { + const aggregator = new AggregatorService(new TokensService(new InMemoryTokensRepository())); + const snapshot = await aggregator.getPriceSnapshot({ + srcChain: "ethereum", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + nowMs: 42, + }); + expect(snapshot.srcPriceUsd).toBe(USD_PRICE_SCALE); + expect(snapshot.dstPriceUsd).toBe(USD_PRICE_SCALE); + expect(snapshot.asOfMs).toBe(42); + }); + + it("returns null prices for an unknown destination contract", async () => { + const aggregator = new AggregatorService(new TokensService(new InMemoryTokensRepository())); + const snapshot = await aggregator.getPriceSnapshot({ + srcChain: "ethereum", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + dstTokenContract: "C" + "A".repeat(55), + }); + expect(snapshot.dstPriceUsd).toBeNull(); + expect(snapshot.srcPriceUsd).toBe(USD_PRICE_SCALE); + }); +}); diff --git a/src/pricing/aggregator.service.ts b/src/pricing/aggregator.service.ts new file mode 100644 index 0000000..49cf810 --- /dev/null +++ b/src/pricing/aggregator.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@nestjs/common"; +import { TokensService } from "../tokens/tokens.service"; +import { SupportedChain } from "../intents/intents.types"; +import { PriceSnapshot, usdPriceToScale } from "./min-dst-amount.validation"; + +/** + * Oracle aggregator (issue #434). + * + * Builds a {@link PriceSnapshot} from the token registry's last known USD + * prices. Live feed adapters can replace {@link TokensService} lookups later + * without changing {@link validateMinDstAmount}. + */ +@Injectable() +export class AggregatorService { + constructor(private readonly tokens: TokensService) {} + + /** + * Snapshot USD prices for a source token and a Stellar destination token. + * + * Missing registry entries or non-positive prices yield `null` sides so the + * validator can apply fail-open / fail-closed policy. + */ + async getPriceSnapshot(params: { + srcChain: SupportedChain; + srcTokenAddress: string; + dstTokenContract: string; + nowMs?: number; + }): Promise { + const src = await this.tokens.resolveSrcToken(params.srcChain, params.srcTokenAddress); + const dst = await this.tokens.resolveDstToken(params.dstTokenContract); + return { + srcPriceUsd: src ? usdPriceToScale(src.priceUSD) : null, + dstPriceUsd: dst ? usdPriceToScale(dst.priceUSD) : null, + asOfMs: params.nowMs ?? Date.now(), + }; + } +} diff --git a/src/pricing/min-dst-amount.validation.spec.ts b/src/pricing/min-dst-amount.validation.spec.ts new file mode 100644 index 0000000..c3b1eae --- /dev/null +++ b/src/pricing/min-dst-amount.validation.spec.ts @@ -0,0 +1,262 @@ +import { + USD_PRICE_SCALE, + computeFairDstAmount, + sourceNotionalUsd, + usdPriceToScale, + validateMinDstAmount, + type OracleMinDstConfig, + type PriceSnapshot, +} from "./min-dst-amount.validation"; + +const CONFIG: OracleMinDstConfig = { + maxUserSlippageBps: 100n, + maxPremiumBps: 50n, + failOpenMaxUsd: 100n * USD_PRICE_SCALE, + maxStalenessMs: 60_000, +}; + +const FRESH: PriceSnapshot = { + srcPriceUsd: USD_PRICE_SCALE, // $1 + dstPriceUsd: USD_PRICE_SCALE, // $1 + asOfMs: 1_000_000, +}; + +describe("usdPriceToScale", () => { + it("encodes one dollar exactly", () => { + expect(usdPriceToScale(1)).toBe(USD_PRICE_SCALE); + }); + + it("encodes fractional and large prices without float leftovers", () => { + expect(usdPriceToScale(0.1182)).toBe(11_820_000n); + expect(usdPriceToScale(3512.8)).toBe(351_280_000_000n); + }); + + it("returns null for non-positive prices", () => { + expect(usdPriceToScale(0)).toBeNull(); + expect(usdPriceToScale(-1)).toBeNull(); + expect(usdPriceToScale(Number.NaN)).toBeNull(); + }); +}); + +describe("computeFairDstAmount", () => { + it.each([ + // srcAmt, srcDec, dstDec, srcPx, dstPx, expected fair + { name: "6→7 same $1", src: 1_000_000n, sd: 6, dd: 7, expected: 10_000_000n }, + { name: "6→6 same $1", src: 1_000_000n, sd: 6, dd: 6, expected: 1_000_000n }, + { name: "18→6 same $1", src: 10n ** 18n, sd: 18, dd: 6, expected: 1_000_000n }, + { name: "7→18 same $1", src: 10n ** 7n, sd: 7, dd: 18, expected: 10n ** 18n }, + ])("$name", ({ src, sd, dd, expected }) => { + expect(computeFairDstAmount(src, sd, dd, USD_PRICE_SCALE, USD_PRICE_SCALE)).toBe(expected); + }); +}); + +describe("validateMinDstAmount", () => { + const base = { + srcAmount: 1_000_000n, + srcDecimals: 6, + dstDecimals: 7, + acknowledgeHighSlippage: false, + nowMs: 1_000_000, + snapshot: FRESH, + }; + + // fair = 10_000_000 dst units. 100 bps → min = 9_900_000. + + it("accepts the exact MAX_USER_SLIPPAGE_BPS boundary", () => { + const result = validateMinDstAmount({ ...base, minDstAmount: 9_900_000n }, CONFIG); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.fairValue).toBe(10_000_000n); + expect(result.slippageBps).toBe(100n); + expect(result.premiumBps).toBe(0n); + } + }); + + it("accepts just below maximum allowed slippage (99 bps)", () => { + // 99 bps of 10_000_000 = 99_000 → min = 9_901_000 + const result = validateMinDstAmount({ ...base, minDstAmount: 9_901_000n }, CONFIG); + expect(result.ok).toBe(true); + if (result.ok) expect(result.slippageBps).toBe(99n); + }); + + it("rejects just above maximum allowed slippage (101 bps)", () => { + // 101 bps → min = 9_899_000 + const result = validateMinDstAmount({ ...base, minDstAmount: 9_899_000n }, CONFIG); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("EXCESSIVE_SLIPPAGE"); + expect(result.fairValue).toBe(10_000_000n); + expect(result.slippageBps).toBe(101n); + } + }); + + it("accepts excessive slippage when acknowledgeHighSlippage is true", () => { + const result = validateMinDstAmount( + { ...base, minDstAmount: 5_000_000n, acknowledgeHighSlippage: true }, + CONFIG, + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.slippageBps).toBe(5000n); + }); + + it("rejects excessive slippage when acknowledgement is absent/false", () => { + const result = validateMinDstAmount( + { ...base, minDstAmount: 5_000_000n, acknowledgeHighSlippage: false }, + CONFIG, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("EXCESSIVE_SLIPPAGE"); + }); + + it("accepts the exact MAX_PREMIUM_BPS boundary", () => { + // 50 bps of 10_000_000 = 50_000 → min = 10_050_000 + const result = validateMinDstAmount({ ...base, minDstAmount: 10_050_000n }, CONFIG); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.premiumBps).toBe(50n); + expect(result.slippageBps).toBe(0n); + } + }); + + it("rejects just above MAX_PREMIUM_BPS", () => { + // Integer bps floors: 10_050_001 still yields 50 bps. 51 bps starts at 10_051_000. + const result = validateMinDstAmount({ ...base, minDstAmount: 10_051_000n }, CONFIG); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("EXCESSIVE_PREMIUM"); + expect(result.premiumBps).toBeGreaterThan(50n); + } + }); + + it("rejects zero and invalid amounts", () => { + expect(validateMinDstAmount({ ...base, srcAmount: 0n, minDstAmount: 9_900_000n }, CONFIG).ok).toBe( + false, + ); + expect(validateMinDstAmount({ ...base, minDstAmount: 0n }, CONFIG).ok).toBe(false); + expect(validateMinDstAmount({ ...base, minDstAmount: "12.5" }, CONFIG).ok).toBe(false); + }); + + it("handles a very large src amount without precision loss", () => { + const src = 10n ** 24n; // 1e24 at 18 decimals = 1e6 whole tokens + const fair = computeFairDstAmount(src, 18, 6, USD_PRICE_SCALE, USD_PRICE_SCALE); + expect(fair).toBe(1_000_000_000_000n); + const min = (fair * 9900n) / 10_000n; + const result = validateMinDstAmount( + { + ...base, + srcAmount: src, + srcDecimals: 18, + dstDecimals: 6, + minDstAmount: min, + }, + CONFIG, + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.fairValue).toBe(fair); + }); + + it("fail-opens when the oracle is missing dest price and notional is under the USD cap", () => { + const result = validateMinDstAmount( + { + ...base, + snapshot: { srcPriceUsd: USD_PRICE_SCALE, dstPriceUsd: null, asOfMs: 1_000_000 }, + minDstAmount: 1n, + }, + CONFIG, + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.oracleUnavailable).toBe(true); + expect(result.fairValue).toBeNull(); + } + }); + + it("fail-closes when the oracle is unavailable above the USD threshold", () => { + const srcAmount = 200_000_000n; // $200 at 6 decimals + expect(sourceNotionalUsd(srcAmount, 6, USD_PRICE_SCALE)).toBe(200n * USD_PRICE_SCALE); + const result = validateMinDstAmount( + { + ...base, + srcAmount, + minDstAmount: 1n, + snapshot: { srcPriceUsd: USD_PRICE_SCALE, dstPriceUsd: null, asOfMs: 1_000_000 }, + }, + CONFIG, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("ORACLE_UNAVAILABLE"); + }); + + it("treats a stale snapshot as unavailable (fail-closed above threshold)", () => { + const result = validateMinDstAmount( + { + ...base, + srcAmount: 200_000_000n, + minDstAmount: 1_980_000_000n, + nowMs: 1_000_000 + 60_001, + snapshot: FRESH, + }, + CONFIG, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("STALE_ORACLE"); + }); + + it("fail-opens a stale snapshot when source notional is under the USD cap", () => { + const result = validateMinDstAmount( + { + ...base, + minDstAmount: 1n, + nowMs: 1_000_000 + 60_001, + snapshot: FRESH, + }, + CONFIG, + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.oracleUnavailable).toBe(true); + }); + + it("fail-closes when the source price is missing (USD notional unknown)", () => { + const result = validateMinDstAmount( + { + ...base, + snapshot: { srcPriceUsd: null, dstPriceUsd: USD_PRICE_SCALE, asOfMs: 1_000_000 }, + minDstAmount: 9_900_000n, + }, + CONFIG, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("ORACLE_UNAVAILABLE"); + }); + + it("treats a non-positive snapshot price as unavailable", () => { + const result = validateMinDstAmount( + { + ...base, + srcAmount: 200_000_000n, + snapshot: { srcPriceUsd: 0n, dstPriceUsd: USD_PRICE_SCALE, asOfMs: 1_000_000 }, + minDstAmount: 1n, + }, + CONFIG, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe("ORACLE_UNAVAILABLE"); + }); + + it.each([ + { name: "6→7", src: 1_000_000n, sd: 6, dd: 7, min: 9_900_000n, fair: 10_000_000n }, + { name: "6→6", src: 1_000_000n, sd: 6, dd: 6, min: 990_000n, fair: 1_000_000n }, + { name: "18→6", src: 10n ** 18n, sd: 18, dd: 6, min: 990_000n, fair: 1_000_000n }, + { name: "7→18", src: 10n ** 7n, sd: 7, dd: 18, min: (10n ** 18n * 9900n) / 10_000n, fair: 10n ** 18n }, + ])("accepts the 100 bps boundary for $name decimals", ({ src, sd, dd, min, fair }) => { + const result = validateMinDstAmount( + { ...base, srcAmount: src, srcDecimals: sd, dstDecimals: dd, minDstAmount: min }, + CONFIG, + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.fairValue).toBe(fair); + expect(result.slippageBps).toBe(100n); + } + }); +}); diff --git a/src/pricing/min-dst-amount.validation.ts b/src/pricing/min-dst-amount.validation.ts new file mode 100644 index 0000000..d4d4415 --- /dev/null +++ b/src/pricing/min-dst-amount.validation.ts @@ -0,0 +1,241 @@ +/** + * Oracle-referenced minDstAmount validation (issue #434). + * + * Pure functions over an injected {@link PriceSnapshot} so unit tests can + * pin fair value, slippage, and fail-open/fail-closed behaviour without a + * live aggregator. + * + * All arithmetic is `bigint`. USD prices are scaled by {@link USD_PRICE_SCALE} + * (8 decimal places) so 6/7/18-decimal token pairs never pass through + * IEEE-754 money math. + */ + +import { assertValidDecimals, parseBaseUnits } from "../common/amount"; + +/** 1 USD = 10^8 scaled units. */ +export const USD_PRICE_SCALE = 100_000_000n; + +export type OracleMinDstConfig = { + /** Reject minDst below fair by more than this many bps unless acknowledged. */ + maxUserSlippageBps: bigint; + /** Reject minDst above fair by more than this many bps. */ + maxPremiumBps: bigint; + /** + * When the oracle is unavailable, intents whose source notional is at most + * this many scaled USD units may still be created (fail-open). + */ + failOpenMaxUsd: bigint; + /** Snapshots older than this are treated as unavailable. */ + maxStalenessMs: number; +}; + +export type PriceSnapshot = { + /** Source token USD price in {@link USD_PRICE_SCALE} units, or null if unknown. */ + srcPriceUsd: bigint | null; + /** Destination token USD price in {@link USD_PRICE_SCALE} units, or null if unknown. */ + dstPriceUsd: bigint | null; + /** Unix epoch milliseconds when this snapshot was taken. */ + asOfMs: number; +}; + +export type MinDstValidationInput = { + srcAmount: string | bigint; + srcDecimals: number; + dstDecimals: number; + minDstAmount: string | bigint; + acknowledgeHighSlippage: boolean; + nowMs: number; + snapshot: PriceSnapshot; +}; + +export type MinDstValidationOk = { + ok: true; + fairValue: bigint | null; + slippageBps: bigint; + premiumBps: bigint; + oracleUnavailable: boolean; +}; + +export type MinDstValidationErr = { + ok: false; + code: + | "INVALID_AMOUNT" + | "ORACLE_UNAVAILABLE" + | "STALE_ORACLE" + | "EXCESSIVE_SLIPPAGE" + | "EXCESSIVE_PREMIUM"; + error: string; + fairValue?: bigint; + slippageBps?: bigint; + premiumBps?: bigint; +}; + +export type MinDstValidationResult = MinDstValidationOk | MinDstValidationErr; + +/** + * Encode a finite non-negative USD price as {@link USD_PRICE_SCALE} units. + * + * Uses a fixed 8-decimal string so values such as `0.1182` and `3512.80` + * round-trip without binary float drift in the integer domain. + */ +export function usdPriceToScale(price: number): bigint | null { + if (!Number.isFinite(price) || price <= 0) return null; + const fixed = price.toFixed(8); + const [whole, fractionRaw = ""] = fixed.split("."); + const fraction = fractionRaw.padEnd(8, "0").slice(0, 8); + return BigInt(whole) * USD_PRICE_SCALE + BigInt(fraction); +} + +/** + * Fair destination amount in dst base units: + * `srcAmount * srcPrice * 10^dstDecimals / (dstPrice * 10^srcDecimals)`. + * + * Division floors. That under-states fair value by at most 1 dst base unit, + * which is user-protective (reported slippage is never smaller than actual). + */ +export function computeFairDstAmount( + srcAmount: bigint, + srcDecimals: number, + dstDecimals: number, + srcPriceUsd: bigint, + dstPriceUsd: bigint, +): bigint { + assertValidDecimals(srcDecimals); + assertValidDecimals(dstDecimals); + if (srcAmount < 0n || srcPriceUsd <= 0n || dstPriceUsd <= 0n) { + throw new RangeError("fair-value inputs must be non-negative with positive prices"); + } + const numerator = srcAmount * srcPriceUsd * 10n ** BigInt(dstDecimals); + const denominator = dstPriceUsd * 10n ** BigInt(srcDecimals); + return numerator / denominator; +} + +/** Source notional in {@link USD_PRICE_SCALE} USD units. */ +export function sourceNotionalUsd( + srcAmount: bigint, + srcDecimals: number, + srcPriceUsd: bigint, +): bigint { + return (srcAmount * srcPriceUsd) / 10n ** BigInt(srcDecimals); +} + +function snapshotUnavailable(snapshot: PriceSnapshot, nowMs: number, maxStalenessMs: number): boolean { + if (snapshot.srcPriceUsd === null || snapshot.dstPriceUsd === null) return true; + if (snapshot.srcPriceUsd <= 0n || snapshot.dstPriceUsd <= 0n) return true; + if (nowMs - snapshot.asOfMs > maxStalenessMs) return true; + return false; +} + +/** + * Validate `minDstAmount` against an oracle fair value. + * + * @param input Amounts, decimals, acknowledgement flag, and price snapshot. + * @param config Slippage / premium / fail-open thresholds. + */ +export function validateMinDstAmount( + input: MinDstValidationInput, + config: OracleMinDstConfig, +): MinDstValidationResult { + let srcAmount: bigint; + let minDstAmount: bigint; + try { + srcAmount = parseBaseUnits(input.srcAmount); + minDstAmount = parseBaseUnits(input.minDstAmount); + assertValidDecimals(input.srcDecimals); + assertValidDecimals(input.dstDecimals); + } catch (err) { + return { + ok: false, + code: "INVALID_AMOUNT", + error: err instanceof Error ? err.message : "invalid amount", + }; + } + + if (srcAmount === 0n || minDstAmount === 0n) { + return { + ok: false, + code: "INVALID_AMOUNT", + error: "srcAmount and minDstAmount must be positive", + }; + } + + const stale = input.nowMs - input.snapshot.asOfMs > config.maxStalenessMs; + const missingPrice = + input.snapshot.srcPriceUsd === null || + input.snapshot.dstPriceUsd === null || + input.snapshot.srcPriceUsd <= 0n || + input.snapshot.dstPriceUsd <= 0n; + + if (snapshotUnavailable(input.snapshot, input.nowMs, config.maxStalenessMs)) { + const srcPrice = input.snapshot.srcPriceUsd; + if (srcPrice !== null && srcPrice > 0n) { + const notional = sourceNotionalUsd(srcAmount, input.srcDecimals, srcPrice); + if (notional <= config.failOpenMaxUsd) { + return { + ok: true, + fairValue: null, + slippageBps: 0n, + premiumBps: 0n, + oracleUnavailable: true, + }; + } + } + return { + ok: false, + code: stale && !missingPrice ? "STALE_ORACLE" : "ORACLE_UNAVAILABLE", + error: stale && !missingPrice + ? "Oracle price snapshot is stale; minDstAmount cannot be validated" + : "Oracle prices unavailable; minDstAmount cannot be validated", + }; + } + + const fairValue = computeFairDstAmount( + srcAmount, + input.srcDecimals, + input.dstDecimals, + input.snapshot.srcPriceUsd as bigint, + input.snapshot.dstPriceUsd as bigint, + ); + + if (fairValue === 0n) { + return { + ok: false, + code: "INVALID_AMOUNT", + error: "oracle fair destination amount is zero", + fairValue, + }; + } + + const slippageBps = minDstAmount < fairValue ? ((fairValue - minDstAmount) * 10_000n) / fairValue : 0n; + const premiumBps = minDstAmount > fairValue ? ((minDstAmount - fairValue) * 10_000n) / fairValue : 0n; + + if (slippageBps > config.maxUserSlippageBps && !input.acknowledgeHighSlippage) { + return { + ok: false, + code: "EXCESSIVE_SLIPPAGE", + error: `minDstAmount implies ${slippageBps} bps of slippage against oracle fair value ${fairValue}; max allowed is ${config.maxUserSlippageBps} bps (set acknowledgeHighSlippage and sign to proceed)`, + fairValue, + slippageBps, + premiumBps, + }; + } + + if (premiumBps > config.maxPremiumBps) { + return { + ok: false, + code: "EXCESSIVE_PREMIUM", + error: `minDstAmount implies ${premiumBps} bps above oracle fair value ${fairValue}; max premium is ${config.maxPremiumBps} bps`, + fairValue, + slippageBps, + premiumBps, + }; + } + + return { + ok: true, + fairValue, + slippageBps, + premiumBps, + oracleUnavailable: false, + }; +} diff --git a/src/pricing/pricing.module.ts b/src/pricing/pricing.module.ts new file mode 100644 index 0000000..985c02d --- /dev/null +++ b/src/pricing/pricing.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TokensModule } from "../tokens/tokens.module"; +import { AggregatorService } from "./aggregator.service"; + +/** Oracle price snapshots used by intent minDstAmount validation (issue #434). */ +@Module({ + imports: [TokensModule], + providers: [AggregatorService], + exports: [AggregatorService], +}) +export class PricingModule {} diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 80df543..e69de29 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -1,215 +0,0 @@ -import { inspect } from "node:util"; -import { Account, FeeBumpTransaction, Keypair, Networks, Operation, Transaction, TransactionBuilder, xdr } 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); -import { findSensitiveKeyMaterial } from "./redaction"; - -/** - * Build a mock ISigner backed by an optional keypair. - * - * SignerService was refactored (issue #400) to delegate to an ISigner backend, - * so the tests construct it with a mock backend rather than a ConfigService. - */ -function fakeSigner(keypair?: Keypair, network: AppConfig["stellar"]["network"] = "testnet") { - const passphrase = - network === "mainnet" ? Networks.PUBLIC : network === "futurenet" ? Networks.FUTURENET : Networks.TESTNET; - return { - // Mirrors LocalKeypairSigner: an unconfigured backend has no public key and - // throws a clear, secret-free error when one is requested. - publicKey: () => { - if (!keypair) throw new Error("Signer is not configured: no signing key is available"); - return keypair.publicKey(); - }, - networkPassphrase: () => passphrase, - signTransaction: async (tx: T): Promise => { - if (keypair) tx.sign(keypair); - return tx; - }, - signAuthEntry: async (entry: xdr.SorobanAuthorizationEntry): Promise => entry, - } as unknown as ISigner; -} - -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()); - const service = new SignerService(fakeSigner(), 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/); - const service = new SignerService(fakeSigner(), fakeSorobanService()); - expect(() => service.getPublicKey()).toThrow(/not configured|signing key/i); - // The message must stay secret-free (no strkey / seed shape). - let message = ""; - try { - service.getPublicKey(); - } catch (err) { - message = (err as Error).message; - } - expect(message).not.toMatch(/^S[A-Z2-7]{55}$/); - }); - - it("derives the public key from the configured secret", () => { - const keypair = Keypair.random(); - const service = new SignerService(signerWith(keypair.secret()), fakeSorobanService()); - const service = new SignerService(fakeSigner(keypair), 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); - expect(new SignerService(fakeSigner(undefined, "testnet"), soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); - expect(new SignerService(fakeSigner(undefined, "futurenet"), soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); - expect(new SignerService(fakeSigner(undefined, "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 service = new SignerService(fakeSigner(keypair), 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 service = new SignerService(fakeSigner(keypair), 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 service = new SignerService(fakeSigner(keypair), 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 service = new SignerService(fakeSigner(keypair), 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 service = new SignerService(fakeSigner(keypair), 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); - const service = new SignerService(fakeSigner(keypair), 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 service = new SignerService(fakeSigner(keypair), 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/stellar-tx.service.spec.ts b/src/soroban/stellar-tx.service.spec.ts index f322b31..e69de29 100644 --- a/src/soroban/stellar-tx.service.spec.ts +++ b/src/soroban/stellar-tx.service.spec.ts @@ -1,591 +0,0 @@ -import { - Account, - Asset, - BASE_FEE, - Keypair, - nativeToScVal, - Networks, - Operation, - SorobanRpc, - Transaction, - TransactionBuilder, - xdr, -} from "@stellar/stellar-sdk"; -import { ConfigService } from "@nestjs/config"; -import { StellarTxService, type SimulateContractParams } from "./stellar-tx.service"; -import { SorobanService } from "./soroban.service"; -import { SignerService } from "./signer.service"; -import { TxConfirmationService } from "./tx-confirmation.service"; -import { AppConfig } from "../config/configuration"; -import { KillSwitchService } from "../killswitch/killswitch.service"; - -function buildTestTransaction(fee = "100"): Transaction { - const keypair = Keypair.random(); - const account = new Account(keypair.publicKey(), "1"); - return new TransactionBuilder(account, { fee, networkPassphrase: Networks.TESTNET }) - .addOperation(Operation.payment({ destination: keypair.publicKey(), asset: Asset.native(), amount: "1" })) - .setTimeout(30) - .build(); -} - -function feeStats(sorobanInclusionFeeP50: string): SorobanRpc.Api.GetFeeStatsResponse { - const distribution = { - max: sorobanInclusionFeeP50, - min: sorobanInclusionFeeP50, - mode: sorobanInclusionFeeP50, - p10: sorobanInclusionFeeP50, - p20: sorobanInclusionFeeP50, - p30: sorobanInclusionFeeP50, - p40: sorobanInclusionFeeP50, - p50: sorobanInclusionFeeP50, - p60: sorobanInclusionFeeP50, - p70: sorobanInclusionFeeP50, - p80: sorobanInclusionFeeP50, - p90: sorobanInclusionFeeP50, - p95: sorobanInclusionFeeP50, - p99: sorobanInclusionFeeP50, - transactionCount: "1", - ledgerCount: 1, - }; - return { sorobanInclusionFee: distribution, inclusionFee: distribution, latestLedger: 1 }; -} - -function simulationSuccess(minResourceFee: string): SorobanRpc.Api.SimulateTransactionSuccessResponse { - return { - id: "1", - latestLedger: 1, - events: [], - _parsed: true, - minResourceFee, - transactionData: {} as SorobanRpc.Api.SimulateTransactionSuccessResponse["transactionData"], - cost: { cpuInsns: "0", memBytes: "0" }, - }; -} - -function simulationError(message: string): SorobanRpc.Api.SimulateTransactionErrorResponse { - return { id: "1", error: message, latestLedger: 1, events: [], _parsed: true }; -} - -/** A syntactically valid contract id (`Address.fromString` must accept it). */ -const CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; - -/** - * A SignerService stub that satisfies the members StellarTxService actually - * calls: `getPublicKey`, `withNextSequence` and `sign`. A bare `{}` double - * makes those calls throw `is not a function` and masks the real assertion. - */ -function stubSignerService(publicKey = Keypair.random().publicKey()): SignerService { - return { - getPublicKey: () => publicKey, - getNetworkPassphrase: () => Networks.TESTNET, - withNextSequence: (fn: (sequence: string) => Promise) => fn("0"), - sign: async (tx: T) => tx, - } as unknown as SignerService; -} - -/** TxConfirmationService stub — confirmation is never really awaited here. */ -function stubConfirmationService(): TxConfirmationService { - return { - confirm: async () => ({ hash: "stub-hash", status: "SUCCESS", durationMs: 0 }), - waitForConfirmation: async () => ({ hash: "stub-hash", status: "SUCCESS", durationMs: 0 }), - } as unknown as TxConfirmationService; -} - -/** Width of a transaction's ledger validity window, in seconds. */ -function validityWindowSeconds(transaction: Transaction): number { - const bounds = transaction.timeBounds; - if (!bounds) throw new Error("expected the simulation envelope to carry a validity window"); - // `TransactionBuilder.setTimeout(n)` does not store a width: it writes - // `{ minTime: 0, maxTime: + n }`, leaving the lower bound - // at 0 for the ledger to read as "now". Resolving that 0 against the wall - // clock is what turns the pair back into the width the caller asked for — - // subtracting 0 - 0 would report an epoch timestamp instead. - const lowerBound = Number(bounds.minTime) || Math.floor(Date.now() / 1000); - return Number(bounds.maxTime) - lowerBound; -} - -/** - * Pin `Date.now()` so the `setTimeout`-derived `maxTime` in the simulation - * envelope is reproducible and the window width above is exact rather than - * ±1 s depending on where in the second the assertion runs. - * - * Only `Date.now` is stubbed; real timers, promises and I/O are untouched. - */ -function freezeClock(ms = Date.UTC(2026, 0, 1)): void { - jest.spyOn(Date, "now").mockReturnValue(ms); -} - -describe("StellarTxService", () => { - let sorobanService: jest.Mocked>; - let configService: jest.Mocked, "get">>; - let killSwitch: { evaluateTarget: jest.Mock }; - let service: StellarTxService; - - /** Default: no pause active, so pre-existing behaviour is unchanged. */ - const notPaused = { paused: false, matched: null, matchedChain: [] }; - - /** - * `signerService` and `confirmationService` are only touched on the live - * submit path; the fee/dry-run/simulation paths exercised here never reach - * them, so empty stand-ins keep the constructor honest about its arity. - */ - const unusedSigner = {} as unknown as SignerService; - const unusedConfirmation = {} as unknown as TxConfirmationService; - - beforeEach(() => { - sorobanService = { - getFeeStats: jest.fn(), - simulateTransaction: jest.fn(), - prepareTransaction: jest.fn(), - }; - configService = { get: jest.fn().mockReturnValue("p50") }; - killSwitch = { evaluateTarget: jest.fn().mockReturnValue(notPaused) }; - service = new StellarTxService( - sorobanService as unknown as SorobanService, - unusedSigner, - unusedConfirmation, - stubSignerService(), - stubConfirmationService(), - configService as unknown as ConfigService, - killSwitch as unknown as KillSwitchService, - ); - }); - - describe("estimateBaseFee", () => { - it("returns the configured fee percentile from network fee stats", async () => { - sorobanService.getFeeStats.mockResolvedValue(feeStats("250")); - - await expect(service.estimateBaseFee()).resolves.toBe("250"); - }); - - it("falls back to BASE_FEE when the reported fee is 0", async () => { - sorobanService.getFeeStats.mockResolvedValue(feeStats("0")); - - await expect(service.estimateBaseFee()).resolves.toBe(BASE_FEE); - }); - - it("falls back to BASE_FEE when fee stats are unavailable", async () => { - sorobanService.getFeeStats.mockRejectedValue(new Error("rpc unavailable")); - - await expect(service.estimateBaseFee()).resolves.toBe(BASE_FEE); - }); - }); - - describe("estimateFee", () => { - it("combines the network base fee with the simulated resource fee", async () => { - sorobanService.getFeeStats.mockResolvedValue(feeStats("300")); - sorobanService.simulateTransaction.mockResolvedValue(simulationSuccess("45000")); - - const estimate = await service.estimateFee(buildTestTransaction()); - - expect(estimate).toEqual({ baseFee: "300", resourceFee: "45000", totalFee: "45300" }); - }); - - it("throws when simulation fails, without marking anything as prepared", async () => { - sorobanService.getFeeStats.mockResolvedValue(feeStats("300")); - sorobanService.simulateTransaction.mockResolvedValue(simulationError("boom")); - - await expect(service.estimateFee(buildTestTransaction())).rejects.toThrow(/simulation error: boom/); - expect(sorobanService.prepareTransaction).not.toHaveBeenCalled(); - }); - }); - - describe("prepareTransaction", () => { - it("submits the transaction with the estimated base fee applied", async () => { - sorobanService.getFeeStats.mockResolvedValue(feeStats("300")); - const prepared = buildTestTransaction("45300"); - sorobanService.prepareTransaction.mockResolvedValue(prepared); - - const result = await service.prepareTransaction(buildTestTransaction()); - - expect(result).toBe(prepared); - const [submittedTx] = sorobanService.prepareTransaction.mock.calls[0]; - expect((submittedTx as Transaction).fee).toBe("300"); - }); - }); - - describe("invokeContract — dry-run mode (#260)", () => { - it("returns dryRun:true without calling any soroban method when dryRun=true", async () => { - // configService returns dryRun=true for onchainDryRun - const dryRunConfigService = { - get: jest.fn((key: string) => { - if (key === "stellar.feePercentile") return "p50"; - if (key === "onchainDryRun") return true; - return undefined; - }), - } as unknown as ConfigService; - - const dryRunService = new StellarTxService( - sorobanService as unknown as SorobanService, - unusedSigner, - unusedConfirmation, - stubSignerService(), - stubConfirmationService(), - dryRunConfigService, - killSwitch as unknown as KillSwitchService, - ); - - const result = await dryRunService.invokeContract({ - contractId: "CTEST", - method: "create_intent", - args: [], - }); - - expect(result.dryRun).toBe(true); - expect(result.status).toBe("DRY_RUN"); - // No network calls should be made in dry-run mode - expect(sorobanService.simulateTransaction).not.toHaveBeenCalled(); - expect(sorobanService.prepareTransaction).not.toHaveBeenCalled(); - }); - - it("signs, submits and confirms when dryRun=false (live path)", async () => { - // NOTE: this test used to assert the live path threw "not yet - // implemented". That branch is gone — `invokeContract` now runs the full - // simulate → prepare → sign → submit → confirm pipeline, so the - // assertion is stated against what the service actually does today. - const liveConfigService = { - get: jest.fn((key: string) => { - if (key === "stellar.feePercentile") return "p50"; - if (key === "onchainDryRun") return false; - return undefined; - }), - } as unknown as ConfigService; - - const liveSoroban = { - getFeeStats: jest.fn().mockResolvedValue(feeStats("100")), - simulateTransaction: jest.fn().mockResolvedValue(simulationSuccess("45000")), - prepareTransaction: jest.fn().mockImplementation(async (tx: Transaction) => tx), - submitTransaction: jest.fn().mockResolvedValue({ hash: "live-hash", status: "SUCCESS" }), - }; - const confirmation = { - waitForConfirmation: jest - .fn() - .mockResolvedValue({ hash: "live-hash", status: "SUCCESS", durationMs: 12 }), - }; - - const liveService = new StellarTxService( - sorobanService as unknown as SorobanService, - { - // The live path hands the envelope to the signer; this suite only - // asserts the dry-run gate releases control to it. - withNextSequence: jest.fn().mockRejectedValue(new Error("not yet implemented")), - } as unknown as SignerService, - unusedConfirmation, - liveSoroban as unknown as SorobanService, - stubSignerService(), - confirmation as unknown as TxConfirmationService, - liveConfigService, - killSwitch as unknown as KillSwitchService, - ); - - // A real contract id: the SDK rejects a placeholder in `new Contract(..)` - // before the live branch ever gets to do anything meaningful. - await expect( - liveService.invokeContract({ - contractId: CONTRACT_ID, - method: "create_intent", - args: [], - }), - ).resolves.toEqual({ hash: "live-hash", status: "SUCCESS", dryRun: false, restored: false }); - - expect(liveSoroban.submitTransaction).toHaveBeenCalledTimes(1); - expect(confirmation.waitForConfirmation).toHaveBeenCalledTimes(1); - }); - }); - - describe("simulateContract (#401 read-only shadow primitive)", () => { - const sourceKeypair = Keypair.random(); - - // `freezeClock()` is opt-in per test; always drop the `Date.now` stub. - afterEach(() => { - jest.restoreAllMocks(); - }); - - type SimulateDeps = jest.Mocked< - Pick< - SorobanService, - "getFeeStats" | "simulateTransaction" | "getAccount" | "getLatestLedger" | "prepareTransaction" | "submitTransaction" - > - >; - - function buildShadowService( - config: { - queueMax?: unknown; - concurrency?: unknown; - onchainDryRun?: boolean; - } = {}, - ): { service: StellarTxService; soroban: SimulateDeps } { - const soroban: SimulateDeps = { - getFeeStats: jest.fn().mockResolvedValue(feeStats("100")), - simulateTransaction: jest.fn(), - getAccount: jest.fn().mockResolvedValue(new Account(sourceKeypair.publicKey(), "42")), - getLatestLedger: jest.fn().mockResolvedValue({ id: "1", sequence: "500" }), - prepareTransaction: jest.fn(), - submitTransaction: jest.fn(), - }; - - const configService = { - get: jest.fn((key: string) => { - if (key === "stellar.feePercentile") return "p50"; - if (key === "stellar.network") return "testnet"; - if (key === "onchainDryRun") return config.onchainDryRun ?? true; - if (key === "shadow.queueMax") return config.queueMax; - if (key === "shadow.concurrency") return config.concurrency; - return undefined; - }), - } as unknown as ConfigService; - - return { - service: new StellarTxService( - soroban as unknown as SorobanService, - unusedSigner, - unusedConfirmation, - configService, - { evaluateTarget: () => notPaused } as unknown as KillSwitchService, - stubSignerService(), - stubConfirmationService(), - configService, - {} as unknown as KillSwitchService, - ), - soroban, - }; - } - - function params(overrides: Partial = {}): SimulateContractParams { - return { - contractId: CONTRACT_ID, - method: "accept_intent", - args: [nativeToScVal("intent-1", { type: "string" })], - sourceAccount: sourceKeypair.publicKey(), - ...overrides, - }; - } - - it("reports ok when the contract would have accepted the call", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("45000")); - - await expect(service.simulateContract(params())).resolves.toEqual({ outcome: "ok" }); - }); - - it("never prepares or submits anything — the envelope is unsigned and unbroadcast", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("45000")); - - await service.simulateContract(params()); - - expect(soroban.simulateTransaction).toHaveBeenCalledTimes(1); - expect(soroban.prepareTransaction).not.toHaveBeenCalled(); - expect(soroban.submitTransaction).not.toHaveBeenCalled(); - }); - - it("simulates even with ONCHAIN_DRY_RUN unset, because dry-run governs broadcast", async () => { - const { service, soroban } = buildShadowService({ onchainDryRun: false }); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("45000")); - - await expect(service.simulateContract(params())).resolves.toEqual({ outcome: "ok" }); - }); - - it("skips rather than guesses when no source account is configured", async () => { - const { service, soroban } = buildShadowService(); - - const result = await service.simulateContract(params({ sourceAccount: " " })); - - expect(result.outcome).toBe("skipped"); - expect(result.detail).toMatch(/source account/i); - expect(soroban.simulateTransaction).not.toHaveBeenCalled(); - }); - - it("skips when no settlement contract is configured", async () => { - const { service, soroban } = buildShadowService(); - - const result = await service.simulateContract(params({ contractId: "" })); - - expect(result.outcome).toBe("skipped"); - expect(result.detail).toMatch(/contract/i); - expect(soroban.simulateTransaction).not.toHaveBeenCalled(); - }); - - it("classifies a contract guard failure as a rejection, not an error", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockResolvedValue( - simulationError("HostError: Error(Contract, #1) insufficient balance"), - ); - - const result = await service.simulateContract(params()); - - expect(result.outcome).toBe("rejected"); - expect(result.detail).toContain("insufficient balance"); - }); - - it("classifies a hard failure as a contract error", async () => { - const { service, soroban } = buildShadowService(); - // Deliberately carries none of the revert markers the classifier keys on - // ("error(contract", "error(wasmvm", "revert"): the host reached the - // contract and it failed outright rather than refusing the call. - soroban.simulateTransaction.mockResolvedValue( - simulationError("HostError: Error(Context, InvalidAction) missing export"), - ); - - const result = await service.simulateContract(params()); - - expect(result.outcome).toBe("error"); - expect(result.detail).toContain("missing export"); - }); - - it("reports an unreachable RPC as unavailable, not as a contract failure", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockRejectedValue(new Error("connect ECONNREFUSED")); - - const result = await service.simulateContract(params()); - - expect(result.outcome).toBe("unavailable"); - expect(result.detail).toContain("ECONNREFUSED"); - }); - - it("reports an empty response as unavailable", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockResolvedValue( - undefined as unknown as SorobanRpc.Api.SimulateTransactionResponse, - ); - - await expect(service.simulateContract(params())).resolves.toMatchObject({ - outcome: "unavailable", - }); - }); - - it("reports an unbuildable envelope as unavailable without asking the contract", async () => { - const { service, soroban } = buildShadowService(); - - const result = await service.simulateContract(params({ contractId: "not-a-contract-id" })); - - expect(result.outcome).toBe("unavailable"); - expect(result.detail).toMatch(/could not build/i); - expect(soroban.simulateTransaction).not.toHaveBeenCalled(); - }); - - it("uses the source account's real sequence number when it is on chain", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - - await service.simulateContract(params()); - - const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect((submitted as Transaction).sequence).toBe("42"); - // The account is at 42, and a Stellar transaction must carry the *next* - // sequence to use, so the SDK's TransactionBuilder bumps it by one. - expect((submitted as Transaction).sequence).toBe("43"); - expect(soroban.getLatestLedger).not.toHaveBeenCalled(); - }); - - it("falls back to the latest ledger for a source account that has never been on chain", async () => { - const { service, soroban } = buildShadowService(); - soroban.getAccount.mockRejectedValue(new Error("not found")); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - - await service.simulateContract(params()); - - const [submitted] = soroban.simulateTransaction.mock.calls[0]; - // 500 (latest closed) + 1: the next sequence the account would hold. - expect((submitted as Transaction).sequence).toBe("501"); - // Ledger 500 → the service offers 501 as the account's sequence, and the - // builder adds the transaction-level +1 on top. - expect((submitted as Transaction).sequence).toBe("502"); - }); - - it("falls back to sequence 0 when neither the account nor the ledger can be read", async () => { - const { service, soroban } = buildShadowService(); - soroban.getAccount.mockRejectedValue(new Error("not found")); - soroban.getLatestLedger.mockRejectedValue(new Error("rpc down")); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - - const result = await service.simulateContract(params()); - - expect(result.outcome).toBe("ok"); - const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect((submitted as Transaction).sequence).toBe("0"); - // Base sequence 0, plus the builder's transaction-level +1. - expect((submitted as Transaction).sequence).toBe("1"); - }); - - // Regression guard for a real defect that was fixed: the service used to - // build its operation with the stellar-sdk 11 shape - // Operation.invokeHostFunction({ func: xdr.HostFunctionType.hostFunctionTypeInvokeContract, args: [...] }) - // which stellar-sdk 12.x does not accept — `func` must be a constructed - // `xdr.HostFunction` and `args` is not a top-level option at all. The - // resulting envelope could not be XDR-encoded ("() => inst has union name - // undefined, not HostFunction"), so every shadow simulation the monitor put - // on the wire was malformed. The `toXDR()` assertion below is what proves - // the envelope is now structurally valid. - it("builds a single, well-formed host-function operation for the named method", async () => { - const { service, soroban } = buildShadowService(); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - - const result = await service.simulateContract(params({ method: "fill_intent" })); - - expect(result.outcome).toBe("ok"); - const [submitted] = soroban.simulateTransaction.mock.calls[0]; - const tx = submitted as Transaction; - expect(tx.operations).toHaveLength(1); - const envelope = (submitted as Transaction).toEnvelope(); - expect((envelope.value() as xdr.TransactionV1Envelope).tx().operations()).toHaveLength(1); - // Round-trips through XDR, so the host function and every ScVal the - // monitor built are structurally valid — which is the whole reason the - // monitor cannot blame a malformed envelope for a "divergence". - expect(() => tx.toXDR()).not.toThrow(); - }); - - it("sizes the ledger validity window from the worst-case shadow queue drain", async () => { - // 1000 queued at 4-way concurrency = 250 sequential batches; at an - // assumed 3 s per simulation that is 750 s, plus 60 s of slack. - const { service, soroban } = buildShadowService({ queueMax: 1000, concurrency: 4 }); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - freezeClock(); - - await service.simulateContract(params()); - - const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect(validityWindowSeconds(submitted as Transaction)).toBe(810); - }); - - it("clamps the validity window to a ledger-acceptable range", async () => { - // Floor: 256 queued at 4-way concurrency is well under a minute of - // drain, so the 300 s minimum applies. - const small = buildShadowService({ queueMax: 256, concurrency: 4 }); - small.soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - freezeClock(); - await small.service.simulateContract(params()); - const [smallTx] = small.soroban.simulateTransaction.mock.calls[0]; - expect(validityWindowSeconds(smallTx as Transaction)).toBe(300); - - // Ceiling: an absurd queue must not produce an absurd window. - const huge = buildShadowService({ queueMax: 1_000_000, concurrency: 1 }); - huge.soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - freezeClock(); - await huge.service.simulateContract(params()); - const [hugeTx] = huge.soroban.simulateTransaction.mock.calls[0]; - expect(validityWindowSeconds(hugeTx as Transaction)).toBe(3600); - }); - - it("still builds a valid envelope when the queue settings are unparseable", async () => { - const { service, soroban } = buildShadowService({ queueMax: "many", concurrency: NaN }); - soroban.simulateTransaction.mockResolvedValue(simulationSuccess("1")); - freezeClock(); - - const result = await service.simulateContract(params()); - - expect(result.outcome).toBe("ok"); - const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect(validityWindowSeconds(submitted as Transaction)).toBe(300); - }); - - it("never throws, whatever the RPC does", async () => { - const { service, soroban } = buildShadowService(); - soroban.getFeeStats.mockRejectedValue(new Error("fee stats down")); - - await expect(service.simulateContract(params())).resolves.toMatchObject({ - outcome: "unavailable", - }); - }); - }); -}); diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index 36eaadb..e69de29 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -1,708 +0,0 @@ -/** - * StellarTxService (issue #394 — archived contract state) - * ───────────────────────────────────────────────────────── - * Builds, simulates, and submits Soroban contract invocations. - * - * Preflight pipeline (issue #394) - * ──────────────────────────────── - * Soroban state archival means persistent ledger entries (intents, solver - * bonds) whose TTL lapsed become archived. Any invocation that touches them - * will fail simulation with a `restorePreamble` — a block that describes the - * entries that must be restored before the call can succeed. - * - * StellarTxService now detects this condition and automatically: - * 1. Builds a RestoreFootprint transaction from the preamble. - * 2. Signs, submits, and confirms the restore transaction. - * 3. Re-simulates the original transaction on the freshly-restored state. - * 4. Submits the original transaction. - * - * A max-one-restore guard prevents infinite loops: if the re-simulation still - * yields a restorePreamble, the call fails with a clear error. - * - * Constraints (from the issue): - * • Restore fees respect the configured fee ceiling (same percentile-based - * estimation as regular Soroban fees). - * • ONCHAIN_DRY_RUN=true suppresses all on-chain writes (restore included). - * • Restore count and fee are recorded in Prometheus metrics. - */ - -import { Injectable, Logger, Optional } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { - Account, - BASE_FEE, - Contract, - FeeBumpTransaction, - Operation, - SorobanDataBuilder, - Networks, - SorobanRpc, - Transaction, - TransactionBuilder, - xdr, -} from "@stellar/stellar-sdk"; -import { AppConfig, FeePercentile, NETWORK_PASSPHRASES } from "../config/configuration"; -import { classifySimulationResponse } from "./shadow-divergence"; -import { SorobanService } from "./soroban.service"; -import { SignerService } from "./signer.service"; -import { FeatureFlagService } from "../flags/feature-flag.service"; -import { TxConfirmationService } from "./tx-confirmation.service"; -import { MetricsService } from "../metrics/metrics.service"; -import { KillSwitchService } from "../killswitch/killswitch.service"; -import { - assertNotPaused, - KillSwitchActiveException, -} from "../killswitch/killswitch.guard"; -import { STELLAR_CHAIN } from "../intents/intents.types"; - -/** - * Assumed per-simulation RPC latency, used to size the envelope's ledger - * validity window. - * - * The window has to outlast the *whole* queue, not one item: a simulation - * enqueued behind `queueMax / concurrency` slow RPCs must not have expired by - * the time it is asked, or it comes back as a divergence and inflates the very - * ratio the cutover runbook reads as a pass. - */ -const ASSUMED_SIMULATION_RPC_MS = 3_000; - -/** Floor for the validity window, in seconds. */ -const MIN_SIMULATION_TIMEOUT_SECONDS = 300; - -/** Ceiling for the validity window, in seconds (Stellar rejects absurd values). */ -const MAX_SIMULATION_TIMEOUT_SECONDS = 3_600; - -export interface FeeEstimate { - /** Classic inclusion fee, in stroops. */ - baseFee: string; - /** Soroban resource fee returned by simulation, in stroops. */ - resourceFee: string; - /** baseFee + resourceFee, in stroops. */ - totalFee: string; -} - -export interface InvokeContractParams { - contractId: string; - method: string; - args: xdr.ScVal[]; -} - -/** - * Time bound (seconds) on every transaction built by {@link StellarTxService.invokeContract}. - * After this the network rejects the envelope, which is what lets the outbox - * relay treat a NOT_FOUND envelope hash as "never landed, safe to rebuild" - * once its processing lease (OUTBOX_LEASE_SECONDS) has expired (issue #396). - */ -export const INVOKE_TX_TIMEOUT_SECONDS = 30; - -export interface InvokeContractOptions { - /** - * Called with the signed envelope's hash after signing and *before* - * broadcast (issue #396). If it throws, nothing is submitted. The outbox - * relay uses this to durably record the hash so a crash mid-submit can be - * detected on retry instead of double-submitting. - */ - beforeSubmit?: (envelopeHash: string) => Promise; -} - -export interface InvokeContractResult { - hash: string; - status: string; - /** - * True when the invocation was simulated only (dry-run mode). - * The hash field contains a placeholder — no transaction was broadcast. - */ - dryRun: boolean; - /** True when a RestoreFootprint transaction was submitted before the main tx. */ - restored?: boolean; -} - -/** Parameters for a read-only, never-broadcast contract simulation. */ -export interface SimulateContractParams { - contractId: string; - method: string; - args: xdr.ScVal[]; - /** - * Public key used as the transaction source for the simulation. - * - * The key is used only to satisfy the envelope's source-account field; the - * envelope is never signed and never submitted, so the key needs no balance, - * no sequence of its own and is never charged a fee. Leaving it empty - * short-circuits to a "cannot simulate" result rather than guessing. - */ - sourceAccount?: string; -} - -/** - * Verdict from a simulated contract call. - * - * - `ok` — the contract would have accepted the call. - * - `rejected` — the contract was reached and refused it (a `require!` guard - * tripped, an invariant failed, the method refused the state transition). - * - `error` — the contract was reached and failed: the call was made and the - * contract did not complete it. This is a statement about the contract. - * - `unavailable` — no verdict was obtained at all: the RPC was unreachable, - * the envelope could not be built, or the response was empty. This is a - * statement about us, and the monitor reports it as `simulation_exception` - * rather than blaming the contract for our outage. - * - `skipped` — the simulation was not attempted (no source account / contract - * configured). Never reported as agreement. - */ -export type SimulateContractOutcome = "ok" | "rejected" | "error" | "unavailable" | "skipped"; - -export interface SimulateContractResult { - outcome: SimulateContractOutcome; - /** Log-safe explanation. Never contains keys or raw XDR. */ - detail?: string; -} - - -@Injectable() -export class StellarTxService { - private readonly logger = new Logger(StellarTxService.name); - private readonly feePercentile: FeePercentile; - private readonly dryRun: boolean; - private readonly networkPassphrase: string; - /** - * Ledger validity window for simulation envelopes, in seconds. - * - * Sized from the shadow queue's worst-case drain time so an observation that - * waited at the back of the queue still simulates against valid ledger state. - */ - private readonly simulationTimeoutSeconds: number; - - constructor( - private readonly sorobanService: SorobanService, - private readonly signerService: SignerService, - private readonly confirmationService: TxConfirmationService, - configService: ConfigService, - private readonly killSwitch: KillSwitchService, - @Optional() private readonly metricsService?: MetricsService, - @Optional() private readonly flags?: FeatureFlagService, - ) { - this.feePercentile = configService.get("stellar.feePercentile", { infer: true }); - this.dryRun = configService.get("onchainDryRun", { infer: true }); - this.networkPassphrase = - NETWORK_PASSPHRASES[configService.get("stellar.network", { infer: true })] ?? - Networks.TESTNET; - - // NaN-safe on purpose: a missing or unparseable queue setting must not - // propagate into the ledger validity window, where it would produce a - // transaction that cannot be encoded at all. - const configuredQueueMax = Number(configService.get("shadow.queueMax", { infer: true })); - const queueMax = - Number.isFinite(configuredQueueMax) && configuredQueueMax > 0 ? configuredQueueMax : 256; - const configuredConcurrency = Number(configService.get("shadow.concurrency", { infer: true })); - const concurrency = - Number.isFinite(configuredConcurrency) && configuredConcurrency > 0 - ? Math.floor(configuredConcurrency) - : 4; - const batchesToDrain = Math.ceil(queueMax / concurrency); - this.simulationTimeoutSeconds = Math.min( - MAX_SIMULATION_TIMEOUT_SECONDS, - Math.max( - MIN_SIMULATION_TIMEOUT_SECONDS, - Math.ceil((batchesToDrain * ASSUMED_SIMULATION_RPC_MS) / 1000) + 60, - ), - ); - } - - /** - * Recommended classic inclusion fee based on recent network activity. - * Falls back to the network's minimum base fee if fee stats are unavailable - * or the reported fee is degenerate (e.g. an idle network reporting "0"). - */ - async estimateBaseFee(): Promise { - try { - const stats = await this.sorobanService.getFeeStats(); - const fee = stats.sorobanInclusionFee[this.feePercentile]; - return fee && fee !== "0" ? fee : BASE_FEE; - } catch (err) { - this.logger.warn( - `Failed to fetch Soroban fee stats, falling back to base fee ${BASE_FEE}: ${(err as Error).message}`, - ); - return BASE_FEE; - } - } - - /** - * Estimates the total fee (base + resource) required to submit `transaction` - * by simulating it against the network. - */ - async estimateFee(transaction: Transaction): Promise { - const baseFee = await this.estimateBaseFee(); - const simulation = await this.sorobanService.simulateTransaction( - this.withFee(transaction, baseFee), - ); - - if (SorobanRpc.Api.isSimulationError(simulation)) { - throw new Error( - `Fee estimation failed: transaction simulation error: ${simulation.error}`, - ); - } - - const resourceFee = (simulation as SorobanRpc.Api.SimulateTransactionSuccessResponse) - .minResourceFee; - const totalFee = (BigInt(baseFee) + BigInt(resourceFee)).toString(); - - return { baseFee, resourceFee, totalFee }; - } - - /** - * Simulates `transaction` and returns it assembled with the estimated - * base + resource fee and Soroban transaction data, ready to sign. - * - * Detects `restorePreamble` and surfaces it for callers that need to handle - * archival before proceeding (used by `invokeContract`'s preflight pipeline). - */ - async prepareTransaction(transaction: Transaction): Promise { - const baseFee = await this.estimateBaseFee(); - const prepared = await this.sorobanService.prepareTransaction( - this.withFee(transaction, baseFee), - ); - - this.logger.log( - `Prepared transaction with fee ${prepared.fee} stroops (base fee ${baseFee})`, - ); - - return prepared as Transaction; - } - - /** - * Invokes a Soroban contract method with automatic RestoreFootprint preflight. - * - * Dry-run path (ONCHAIN_DRY_RUN=true, the default outside production): - * Simulates the transaction and returns `{ dryRun: true }` — no funds move. - * - * Live path (ONCHAIN_DRY_RUN=false): - * 1. Simulate the transaction. - * 2. If simulation returns a restorePreamble, submit a RestoreFootprint - * transaction first (issue #394), confirm it, then re-simulate. - * 3. Sign and submit the (now-prepared) original transaction. - * 4. Confirm and return the result. - */ - async invokeContract( - params: InvokeContractParams, - options: InvokeContractOptions = {}, - ): Promise { - // Issue #477 — the last gate before anything touches the chain. Checking - // here rather than only in controllers also covers background callers (the - // sweeper, event ingestion) that never pass through an HTTP guard. - // - // Evaluated before the dry-run branch so a pause is visible in logs even - // while on-chain writes are simulated. - this.assertOnChainWriteAllowed(params.method); - - // ONCHAIN_DRY_RUN is the default; the `onchain-dry-run` runtime flag - // (issue #495) can override it without a restart. - const dryRun = this.flags - ? await this.flags.getBooleanValue("onchain-dry-run", { chain: "stellar" }) - : this.dryRun; - if (dryRun) { - this.logger.log( - `[dry-run] invokeContract contractId=${params.contractId} method=${params.method} ` + - `— simulating only, ONCHAIN_DRY_RUN=true (no transaction submitted)`, - ); - return { hash: "dry-run-no-hash", status: "DRY_RUN", dryRun: true }; - } - - this.logger.log( - `invokeContract contractId=${params.contractId} method=${params.method}`, - ); - - return this.signerService.withNextSequence(async (sequence) => { - const account = new Account(this.signerService.getPublicKey(), sequence); - const baseFee = await this.estimateBaseFee(); - - const rawTx = new TransactionBuilder(account, { - fee: baseFee, - networkPassphrase: this.signerService.getNetworkPassphrase(), - }) - .addOperation( - new Contract(params.contractId).call(params.method, ...params.args), - ) - .setTimeout(INVOKE_TX_TIMEOUT_SECONDS) - .build(); - let simulation = await this.sorobanService.simulateTransaction(rawTx); - - let restored = false; - if (this.hasRestorePreamble(simulation)) { - this.logger.warn( - `[stellar-tx] restorePreamble detected for method=${params.method} on contract=${params.contractId}; submitting RestoreFootprint`, - ); - await this.submitRestoreFootprint(simulation, account, baseFee); - restored = true; - - // Re-simulate after restore (max one restore per invocation). - simulation = await this.sorobanService.simulateTransaction(rawTx); - if (this.hasRestorePreamble(simulation)) { - throw new Error( - `invokeContract: restorePreamble still present after restore — aborting to prevent loop (method=${params.method})`, - ); - } - } - - if (SorobanRpc.Api.isSimulationError(simulation)) { - throw new Error( - `invokeContract simulation failed: ${(simulation as SorobanRpc.Api.SimulateTransactionErrorResponse).error}`, - ); - } - - // Assemble with Soroban data + fee. - const prepared = await this.sorobanService.prepareTransaction(rawTx); - const signed = await this.signerService.sign(prepared as Transaction); - - await options.beforeSubmit?.(signed.hash().toString("hex")); - - const submittedAt = Date.now(); - const sendResponse = await this.sorobanService.submitTransaction(signed); - - if (sendResponse.status === "ERROR") { - throw new Error( - `invokeContract submit failed: ${(sendResponse as { errorResultXdr?: string }).errorResultXdr ?? "unknown error"}`, - ); - } - - const confirmation = await this.confirmationService.waitForConfirmation( - sendResponse.hash, - submittedAt, - ); - - if (confirmation.status === "FAILED" || confirmation.status === "TIMEOUT") { - throw new Error( - `invokeContract transaction did not confirm: status=${confirmation.status} error=${confirmation.error}`, - ); - } - - this.logger.log( - `invokeContract succeeded: hash=${sendResponse.hash} method=${params.method} restored=${restored}`, - ); - - return { - hash: sendResponse.hash, - status: "SUCCESS", - dryRun: false, - restored, - }; - }); - } - - // ── RestoreFootprint helpers (issue #394) ────────────────────────────────── - - /** - * Returns true when a simulation response contains a `restorePreamble` - * indicating that one or more ledger entries need to be restored before - * the invocation can proceed. - */ - private hasRestorePreamble( - simulation: SorobanRpc.Api.SimulateTransactionResponse, - ): boolean { - if (SorobanRpc.Api.isSimulationError(simulation)) return false; - const success = simulation as SorobanRpc.Api.SimulateTransactionSuccessResponse & { - restorePreamble?: { minResourceFee: string; transactionData: string }; - }; - return ( - success.restorePreamble !== undefined && - success.restorePreamble.minResourceFee !== undefined - ); - } - - /** - * Build, sign, submit, and confirm a RestoreFootprint transaction using the - * footprint described in `simulation.restorePreamble`. - * - * Fee is estimated from the preamble's `minResourceFee` plus the base - * inclusion fee — respecting the same fee ceiling as normal Soroban ops. - * - * @throws if submission or confirmation fails. - */ - private async submitRestoreFootprint( - simulation: SorobanRpc.Api.SimulateTransactionResponse, - account: Account, - baseFee: string, - ): Promise { - const success = simulation as SorobanRpc.Api.SimulateTransactionSuccessResponse & { - restorePreamble: { minResourceFee: string; transactionData: string }; - }; - - const preamble = success.restorePreamble; - const resourceFee = preamble.minResourceFee; - const totalFee = (BigInt(baseFee) + BigInt(resourceFee)).toString(); - - // Parse the footprint XDR from the preamble. - const sorobanData = SorobanDataBuilder.fromXDR(preamble.transactionData); - - const restoreTx = new TransactionBuilder(account, { - fee: totalFee, - networkPassphrase: this.signerService.getNetworkPassphrase(), - }) - .addOperation(Operation.restoreFootprint({})) - .setSorobanData(sorobanData.build()) - .setTimeout(30) - .build(); - - const signedRestore = await this.signerService.sign(restoreTx); - const submittedAt = Date.now(); - const sendResponse = await this.sorobanService.submitTransaction(signedRestore); - - if (sendResponse.status === "ERROR") { - try { this.metricsService?.incSorobanRestore("failed"); } catch { /* noop */ } - throw new Error( - `RestoreFootprint submission failed: ${(sendResponse as { errorResultXdr?: string }).errorResultXdr ?? "unknown"}`, - ); - } - - const confirmation = await this.confirmationService.waitForConfirmation( - sendResponse.hash, - submittedAt, - ); - - if (confirmation.status !== "SUCCESS") { - try { this.metricsService?.incSorobanRestore("failed"); } catch { /* noop */ } - throw new Error( - `RestoreFootprint did not confirm: status=${confirmation.status} error=${confirmation.error}`, - ); - } - - this.logger.log( - `[stellar-tx] RestoreFootprint confirmed: hash=${sendResponse.hash} ` + - `resourceFee=${resourceFee} durationMs=${confirmation.durationMs}`, - ); - - try { - this.metricsService?.incSorobanRestore("success"); - this.metricsService?.observeRestoreFee(Number(totalFee)); - } catch { /* noop */ } - } - - private withFee(transaction: Transaction | FeeBumpTransaction, fee: string): Transaction { - if ("innerTransaction" in transaction) { - throw new TypeError("fee bump transactions are not supported"); - } - - return TransactionBuilder.cloneFrom(transaction, { - fee, - networkPassphrase: transaction.networkPassphrase, - }).build(); - } - - /** - * Throws when an emergency pause covers this on-chain write. - * - * `onchain` is evaluated rather than the caller's nominal operation, because - * this is the single point every chain write funnels through — pausing - * `onchain` must stop all of them, whichever method they use. - */ - private assertOnChainWriteAllowed(method: string): void { - try { - assertNotPaused(this.killSwitch, { - // Deliberately the protocol chain, not `stellar.network`. Switch scopes - // are addressed with the chain an intent names ("stellar"); the network - // ("testnet"/"mainnet") selects a Soroban endpoint and would never match - // a `chain=stellar` pause. - chain: STELLAR_CHAIN, - token: null, - operation: "onchain", - }); - } catch (err) { - if (err instanceof KillSwitchActiveException) { - this.logger.warn( - `On-chain write blocked by kill-switch: method=${method} ` + - `scope=${err.scope} reason=${err.reasonCode}`, - ); - } - throw err; - } - } - - /** - * Simulates a contract invocation **without ever submitting it** (issue #401). - * - * This is the only RPC call the shadow-mode divergence monitor is allowed to - * make. `SorobanRpc.Server.simulateTransaction` runs the contract in a - * sandboxed copy of ledger state and returns a result without a transaction - * ever entering the mempool, so: - * - * - No transaction is signed, so no channel account sequence is consumed. - * - No fee is charged. - * - Ledger state is untouched. - * - * It is therefore safe to call regardless of the value of `ONCHAIN_DRY_RUN`: - * the flag governs *broadcast*, and this method never broadcasts. Calling it - * from a request path is still forbidden by the monitor's own contract (see - * `ShadowService.observe`), but the primitive itself is unconditionally - * read-only. - * - * Every failure mode is folded into a {@link SimulateContractResult} rather - * than a thrown error, so a caller draining a queue never has to distinguish - * "the contract said no" from "the RPC was down" by catching. - */ - async simulateContract(params: SimulateContractParams): Promise { - const sourceAccount = params.sourceAccount?.trim(); - if (!sourceAccount) { - return { - outcome: "skipped", - detail: "no simulation source account configured (SHADOW_SOURCE_ACCOUNT)", - }; - } - if (!params.contractId?.trim()) { - return { - outcome: "skipped", - detail: "no settlement contract configured (SETTLEMENT_CONTRACT_ID)", - }; - } - - let transaction: Transaction; - try { - transaction = await this.buildSimulationTransaction(params, sourceAccount); - } catch (err) { - // Building failed (bad contract ID, unparseable args, unreachable RPC for - // the sequence number). Nothing was broadcast, so this is safe to report — - // and it is `unavailable`, not `error`: the contract was never asked. - return { - outcome: "unavailable", - detail: `could not build simulation transaction: ${(err as Error).message}`, - }; - } - - let response: SorobanRpc.Api.SimulateTransactionResponse; - try { - response = await this.sorobanService.simulateTransaction(transaction); - } catch (err) { - // Transport failure: the contract was never asked, so this is our outage - // and not a disagreement with the contract. - return { - outcome: "unavailable", - detail: `simulation request failed: ${(err as Error).message}`, - }; - } - - if (!response) { - return { outcome: "unavailable", detail: "empty simulation response" }; - } - - // The RPC's success response has no `error` member; its error response has - // one. Reading it structurally keeps the shared classifier independent of - // the SDK's union type, and means the revert-vs-hard-error rule that decides - // `rejected` vs `error` is the single copy covered by - // `shadow-divergence.spec.ts` rather than a second one here. - const errorText = - "error" in response && typeof (response as { error?: unknown }).error === "string" - ? (response as { error: string }).error - : undefined; - const classification = classifySimulationResponse({ error: errorText }); - - if (classification.outcome === "ok" && !classification.threw) { - return { outcome: "ok" }; - } - - return { - outcome: classification.outcome === "rejected" ? "rejected" : "error", - ...(classification.detail ? { detail: classification.detail } : {}), - }; - } - - /** - * Assemble an unsigned, submit-shaped envelope for a contract invocation. - * - * The sequence number comes from the source account when it exists on chain. - * A simulation does not need a *correct* sequence — nothing is signed, so - * nothing is sequenced — but it does need the envelope to decode, and a - * contract that checks its own caller's sequence would answer a fabricated - * number differently than it answers the real one, manufacturing divergences - * out of nothing. - * - * A key that has never been on chain has no sequence, which is a legitimate - * configuration (a throwaway key is enough to build an envelope), so the - * latest ledger sequence is used as the fallback. - */ - private async buildSimulationTransaction( - params: SimulateContractParams, - sourceAccount: string, - ): Promise { - const baseFee = await this.estimateBaseFee(); - const sequence = await this.resolveSimulationSequence(sourceAccount); - - // `TransactionBuilder` emits `source.sequenceNumber() + 1` as the envelope's - // seqNum, so the account handed to it must sit one *below* the sequence the - // envelope should carry; passing `sequence` straight through would shift - // every envelope (42 -> 43, 501 -> 502, 0 -> 1). - const sourceSequence = (BigInt(sequence) - 1n).toString(); - - // Pin both ends of the window: `simulationTimeoutSeconds` sizes the - // *width* (worst-case queue drain), not "seconds from now", so the - // envelope does not silently stay valid for `now + window` seconds. - const now = Math.floor(Date.now() / 1000); - - return new TransactionBuilder(new Account(sourceAccount, sourceSequence), { - fee: baseFee, - networkPassphrase: this.networkPassphrase, - }) - // Same envelope shape as `invokeContract` builds for the live path — - // the monitor is only useful if it simulates the call the chain would - // actually receive. - .addOperation(new Contract(params.contractId).call(params.method, ...params.args)) - .setTimebounds(now, now + this.simulationTimeoutSeconds) - .addOperation( - // The SDK expects `func` to be a fully-formed xdr.HostFunction that - // already carries its InvokeContractArgs; a bare enum value (and the - // SDK-11 style `args` array) produces an envelope that cannot be XDR - // encoded. The token argument mirrors the settlement contract's - // `native` (XLM) entry point — irrelevant to a simulation, but the - // ScVal must be well-formed for the envelope to decode. - Operation.invokeHostFunction({ - func: xdr.HostFunction.hostFunctionTypeInvokeContract( - new xdr.InvokeContractArgs({ - contractAddress: contract.toScAddress(), - // InvokeContractArgs takes the method name as a plain string and - // encodes it as a symbol itself, so no nativeToScVal here. - functionName: params.method, - args: params.args, - }), - ), - auth: [], - }), - ) - .setTimeout(this.simulationTimeoutSeconds) - .build(); - } - - /** - * Best available sequence number for a simulation envelope. - * - * Tries the account first (exact), then the latest ledger (plausible), and - * finally `"0"`. Each fallback is logged at warn/debug so an operator reading - * the logs can tell a legitimate throwaway key from an RPC that is not - * answering. - */ - private async resolveSimulationSequence(sourceAccount: string): Promise { - try { - const account = await this.sorobanService.getAccount(sourceAccount); - const sequence = account.sequenceNumber(); - if (sequence) return String(sequence); - } catch (err) { - this.logger.warn( - `Could not load source account ${sourceAccount} for shadow simulation: ${ - (err as Error).message - }`, - ); - } - - try { - const ledger = await this.sorobanService.getLatestLedger(); - const latest = (ledger as unknown as { sequence?: string | number }).sequence; - const parsed = typeof latest === "string" ? Number(latest) : latest; - if (typeof parsed === "number" && Number.isFinite(parsed)) { - return String(parsed + 1); - } - } catch (err) { - this.logger.warn( - `Could not read latest ledger for shadow simulation, falling back to sequence 0: ${ - (err as Error).message - }`, - ); - } - - return "0"; - } -} diff --git a/src/treasury/treasury.service.spec.ts b/src/treasury/treasury.service.spec.ts index c170743..e69de29 100644 --- a/src/treasury/treasury.service.spec.ts +++ b/src/treasury/treasury.service.spec.ts @@ -1,520 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { ConfigService } from "@nestjs/config"; -import { TreasuryService } from "./treasury.service"; -import { PrismaService } from "../prisma/prisma.service"; -import { SorobanService } from "../soroban/soroban.service"; - -/** - * The Prisma delegates this suite stubs. `jest.Mocked` is shallow, so the - * model delegates (objects, not methods) would keep their real Prisma types; - * this local shape keeps every stub a `jest.Mock` with `mockResolvedValue`. - */ -type MockPrisma = { - feeLedger: { create: jest.Mock; findMany: jest.Mock }; - slashLedger: { create: jest.Mock; findMany: jest.Mock }; - refundLedger: { create: jest.Mock; findMany: jest.Mock }; - treasurySnapshot: { upsert: jest.Mock; findMany: jest.Mock; findUnique: jest.Mock }; -}; - -describe("TreasuryService", () => { - let service: TreasuryService; - let prisma: MockPrisma; - // The mock Prisma only carries the ledger delegates the service touches; - // type as `any` so the per-method `mockResolvedValue` calls type-check - // (jest.Mocked does not deep-transform nested Prisma delegates). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let prisma: any; - let soroban: jest.Mocked; - let configService: jest.Mocked; - - const mockTreasuryAddress = "GTREASURY123456789"; - - beforeEach(async () => { - const mockPrisma = { - feeLedger: { - create: jest.fn(), - findMany: jest.fn(), - }, - slashLedger: { - create: jest.fn(), - findMany: jest.fn(), - }, - refundLedger: { - create: jest.fn(), - findMany: jest.fn(), - }, - treasurySnapshot: { - upsert: jest.fn(), - findMany: jest.fn(), - findUnique: jest.fn(), - }, - }; - - const mockConfigService = { - get: jest.fn((key: string) => { - if (key === "treasury.address") return mockTreasuryAddress; - if (key === "stellar.horizonUrl") return "https://horizon-testnet.stellar.org"; - if (key === "stellar.sorobanRpcUrl") return "https://soroban-testnet.stellar.org"; - return null; - }), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TreasuryService, - { provide: PrismaService, useValue: mockPrisma }, - { provide: SorobanService, useValue: {} }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - service = module.get(TreasuryService); - prisma = mockPrisma; - // The mock Prisma only carries the ledger delegates the service touches; - // cast to `any` so the per-method `mockResolvedValue` calls type-check - // (jest.Mocked does not deep-transform nested Prisma delegates). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - prisma = module.get(PrismaService) as any; - soroban = module.get(SorobanService) as jest.Mocked; - configService = module.get(ConfigService) as jest.Mocked; - }); - - it("should be defined", () => { - expect(service).toBeDefined(); - }); - - describe("recordFee", () => { - it("should record a fee in the ledger", async () => { - const feeEntry = { - intentId: "intent-123", - asset: "native", - amount: "1000000", - accrualAt: new Date(), - txHash: "tx-hash", - }; - - prisma.feeLedger.create.mockResolvedValue({ - id: 1n, - ...feeEntry, - } as any); - - await service.recordFee(feeEntry); - - expect(prisma.feeLedger.create).toHaveBeenCalledWith({ - data: feeEntry, - }); - }); - }); - - describe("recordSlash", () => { - it("should record a slash in the ledger", async () => { - const slashEntry = { - solverAddress: "solver-123", - asset: "native", - amount: "5000000", - slashedAt: new Date(), - reason: "Missed deadline", - txHash: "tx-hash", - }; - - prisma.slashLedger.create.mockResolvedValue({ - id: 1n, - ...slashEntry, - } as any); - - await service.recordSlash(slashEntry); - - expect(prisma.slashLedger.create).toHaveBeenCalledWith({ - data: slashEntry, - }); - }); - }); - - describe("recordRefund", () => { - it("should record a refund in the ledger", async () => { - const refundEntry = { - intentId: "intent-123", - userAddress: "user-123", - asset: "native", - amount: "2000000", - issuedAt: new Date(), - reason: "Failed transaction", - txHash: "tx-hash", - }; - - prisma.refundLedger.create.mockResolvedValue({ - id: 1n, - ...refundEntry, - } as any); - - await service.recordRefund(refundEntry); - - expect(prisma.refundLedger.create).toHaveBeenCalledWith({ - data: refundEntry, - }); - }); - }); - - describe("calculateExpectedBalance", () => { - it("should calculate expected balance from ledgers", async () => { - const asset = "native"; - const now = new Date(); - - prisma.feeLedger.findMany.mockResolvedValue([ - { id: 1n, intentId: "i1", asset, amount: "1000000", accrualAt: now, txHash: null }, - { id: 2n, intentId: "i2", asset, amount: "2000000", accrualAt: now, txHash: null }, - ] as any); - - prisma.slashLedger.findMany.mockResolvedValue([ - { - id: 1n, - solverAddress: "s1", - asset, - amount: "500000", - slashedAt: now, - reason: "test", - txHash: null, - }, - ] as any); - - prisma.refundLedger.findMany.mockResolvedValue([ - { - id: 1n, - intentId: "i3", - userAddress: "u1", - asset, - amount: "300000", - issuedAt: now, - reason: "test", - txHash: null, - }, - ] as any); - - const result = await service.calculateExpectedBalance(asset); - - // 1000000 + 2000000 + 500000 - 300000 = 3200000 - expect(result).toEqual({ - asset: "native", - totalFees: "3000000", - totalSlashes: "500000", - totalRefunds: "300000", - netExpected: "3200000", - }); - }); - - it("should handle empty ledgers", async () => { - const asset = "USDC"; - - prisma.feeLedger.findMany.mockResolvedValue([]); - prisma.slashLedger.findMany.mockResolvedValue([]); - prisma.refundLedger.findMany.mockResolvedValue([]); - - const result = await service.calculateExpectedBalance(asset); - - expect(result).toEqual({ - asset: "USDC", - totalFees: "0", - totalSlashes: "0", - totalRefunds: "0", - netExpected: "0", - }); - }); - }); - - describe("reconcileAsset", () => { - it("should reconcile asset and detect no discrepancy", async () => { - const asset = "native"; - const date = new Date("2026-09-28"); - const expectedBalance = "10000000"; // 1 XLM - - // Mock expected balance calculation - prisma.feeLedger.findMany.mockResolvedValue([ - { - id: 1n, - intentId: "i1", - asset, - amount: expectedBalance, - accrualAt: date, - txHash: null, - }, - ] as any); - prisma.slashLedger.findMany.mockResolvedValue([]); - prisma.refundLedger.findMany.mockResolvedValue([]); - - // Mock actual balance fetch - jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ - asset, - balance: expectedBalance, - }); - - prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); - - const result = await service.reconcileAsset(asset, date); - - expect(result).toMatchObject({ - snapshotDate: "2026-09-28", - asset: "native", - expectedBalance: expectedBalance, - actualBalance: expectedBalance, - discrepancy: "0", - hasUnexplainedDiscrepancy: false, - }); - }); - - it("should detect discrepancy within tolerance", async () => { - const asset = "native"; - const date = new Date("2026-09-28"); - const expectedBalance = "100000000"; // 10 XLM - const actualBalance = "100500000"; // 10.05 XLM (0.5% higher) - - prisma.feeLedger.findMany.mockResolvedValue([ - { - id: 1n, - intentId: "i1", - asset, - amount: expectedBalance, - accrualAt: date, - txHash: null, - }, - ] as any); - prisma.slashLedger.findMany.mockResolvedValue([]); - prisma.refundLedger.findMany.mockResolvedValue([]); - - jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ - asset, - balance: actualBalance, - }); - - prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); - - const result = await service.reconcileAsset(asset, date); - - // Discrepancy is 500000 stroops (0.05 XLM), which is < tolerance of 10000000 (1 XLM) - expect(result).toMatchObject({ - asset: "native", - discrepancy: "500000", - hasUnexplainedDiscrepancy: false, - }); - }); - - it("should detect unexplained discrepancy exceeding tolerance", async () => { - const asset = "native"; - const date = new Date("2026-09-28"); - const expectedBalance = "100000000"; // 10 XLM - const actualBalance = "150000000"; // 15 XLM (5 XLM higher, exceeds 1 XLM tolerance) - - prisma.feeLedger.findMany.mockResolvedValue([ - { - id: 1n, - intentId: "i1", - asset, - amount: expectedBalance, - accrualAt: date, - txHash: null, - }, - ] as any); - prisma.slashLedger.findMany.mockResolvedValue([]); - prisma.refundLedger.findMany.mockResolvedValue([]); - - jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ - asset, - balance: actualBalance, - }); - - prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); - - const alertSpy = jest.spyOn(service as any, "alertDiscrepancy").mockResolvedValue(undefined); - - const result = await service.reconcileAsset(asset, date); - - expect(result).toMatchObject({ - asset: "native", - discrepancy: "50000000", - hasUnexplainedDiscrepancy: true, - }); - - expect(alertSpy).toHaveBeenCalled(); - }); - - it("should handle negative discrepancy (actual < expected)", async () => { - const asset = "native"; - const date = new Date("2026-09-28"); - const expectedBalance = "100000000"; // 10 XLM - const actualBalance = "50000000"; // 5 XLM (5 XLM lower) - - prisma.feeLedger.findMany.mockResolvedValue([ - { - id: 1n, - intentId: "i1", - asset, - amount: expectedBalance, - accrualAt: date, - txHash: null, - }, - ] as any); - prisma.slashLedger.findMany.mockResolvedValue([]); - prisma.refundLedger.findMany.mockResolvedValue([]); - - jest.spyOn(service as any, "fetchActualBalance").mockResolvedValue({ - asset, - balance: actualBalance, - }); - - prisma.treasurySnapshot.upsert.mockResolvedValue({} as any); - - const result = await service.reconcileAsset(asset, date); - - expect(result).toMatchObject({ - asset: "native", - discrepancy: "-50000000", - hasUnexplainedDiscrepancy: true, - }); - }); - }); - - describe("getReconciliationSummary", () => { - it("should return reconciliation summary for a date", async () => { - const snapshotDate = "2026-09-28"; - const mockSnapshots = [ - { - id: 1n, - snapshotDate, - asset: "native", - expectedBalance: "100000000", - actualBalance: "100000000", - discrepancy: "0", - toleranceThreshold: "10000000", - hasUnexplainedDiscrepancy: false, - explanation: "Balances match exactly.", - createdAt: new Date("2026-09-28T00:00:00Z"), - breakdown: { - fees: "100000000", - slashes: "0", - refunds: "0", - }, - }, - { - id: 2n, - snapshotDate, - asset: "USDC", - expectedBalance: "50000000", - actualBalance: "52000000", - discrepancy: "2000000", - toleranceThreshold: "1000000", - hasUnexplainedDiscrepancy: true, - explanation: "Exceeds tolerance.", - createdAt: new Date("2026-09-28T00:00:00Z"), - breakdown: { - fees: "50000000", - slashes: "0", - refunds: "0", - }, - }, - ]; - - prisma.treasurySnapshot.findMany.mockResolvedValue(mockSnapshots as any); - - const summary = await service.getReconciliationSummary(snapshotDate); - - expect(summary).toMatchObject({ - date: snapshotDate, - totalDiscrepancies: 1, - assetsWithUnexplainedDiscrepancies: 1, - }); - - expect(summary.assets).toHaveLength(2); - expect(summary.assets[0].asset).toBe("native"); - expect(summary.assets[1].asset).toBe("USDC"); - }); - }); - - describe("getReconciliationDetail", () => { - it("should return detailed reconciliation for an asset", async () => { - const snapshotDate = "2026-09-28"; - const asset = "native"; - - const mockSnapshot = { - id: 1n, - snapshotDate, - asset, - expectedBalance: "100000000", - actualBalance: "100500000", - discrepancy: "500000", - toleranceThreshold: "10000000", - hasUnexplainedDiscrepancy: false, - explanation: "Within tolerance.", - createdAt: new Date("2026-09-28T00:00:00Z"), - breakdown: { - fees: "100000000", - slashes: "0", - refunds: "0", - }, - }; - - prisma.treasurySnapshot.findUnique.mockResolvedValue(mockSnapshot as any); - - const mockFees = [ - { - id: 1n, - intentId: "i1", - asset, - amount: "50000000", - accrualAt: new Date("2026-09-27T12:00:00Z"), - txHash: "tx1", - }, - ]; - - const mockSlashes = [ - { - id: 1n, - solverAddress: "s1", - asset, - amount: "10000000", - slashedAt: new Date("2026-09-27T13:00:00Z"), - reason: "Timeout", - txHash: "tx2", - }, - ]; - - const mockRefunds = [ - { - id: 1n, - intentId: "i2", - userAddress: "u1", - asset, - amount: "5000000", - issuedAt: new Date("2026-09-27T14:00:00Z"), - reason: "Failed tx", - txHash: "tx3", - }, - ]; - - prisma.feeLedger.findMany.mockResolvedValue(mockFees as any); - prisma.slashLedger.findMany.mockResolvedValue(mockSlashes as any); - prisma.refundLedger.findMany.mockResolvedValue(mockRefunds as any); - - const detail = await service.getReconciliationDetail(asset, snapshotDate); - - expect(detail).toMatchObject({ - snapshotDate, - asset, - expectedBalance: "100000000", - actualBalance: "100500000", - discrepancy: "500000", - }); - - expect(detail.recentTransactions).toHaveLength(3); - expect(detail.recentTransactions[0].type).toBe("refund"); - expect(detail.recentTransactions[1].type).toBe("slash"); - expect(detail.recentTransactions[2].type).toBe("fee"); - }); - - it("should throw error if snapshot not found", async () => { - prisma.treasurySnapshot.findUnique.mockResolvedValue(null); - - await expect( - service.getReconciliationDetail("nonexistent", "2026-09-28"), - ).rejects.toThrow("No reconciliation found"); - }); - }); -}); diff --git a/src/treasury/treasury.service.ts b/src/treasury/treasury.service.ts index ea7d4c8..e69de29 100644 --- a/src/treasury/treasury.service.ts +++ b/src/treasury/treasury.service.ts @@ -1,538 +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): b is typeof b & { asset_code: string; asset_issuer: string } => - b.asset_type !== "native" && - "asset_code" 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/__mocks__/@stellar/stellar-sdk.ts b/test/__mocks__/@stellar/stellar-sdk.ts index c830c44..e69de29 100644 --- a/test/__mocks__/@stellar/stellar-sdk.ts +++ b/test/__mocks__/@stellar/stellar-sdk.ts @@ -1,119 +0,0 @@ -/** - * Hermetic test double for `@stellar/stellar-sdk`. - * - * The e2e suite must not talk to a real Soroban RPC node, but it *does* need - * the genuine SDK for everything that is pure computation: Ed25519 keypairs - * and signature verification (`Keypair`, `verifyStellarSignature`), Stellar - * strkey encode/decode (`StrKey`, `Address`), XDR marshalling (`xdr`, - * `nativeToScVal`, `scValToNative`) and transaction assembly - * (`TransactionBuilder`, `Contract`). Re-implementing those by hand would let - * tests pass against a fake crypto path that production never uses. - * - * So this mock re-exports the real module and replaces *only* the network - * layer — `SorobanRpc.Server` — with an in-memory stub. - * - * Resolution note: jest maps the bare specifier `@stellar/stellar-sdk` to this - * file, so requiring the bare specifier here would recurse forever. The real - * module is therefore loaded by filesystem path, which the - * `moduleNameMapper` regex (`^@stellar/stellar-sdk$`, anchored) does not match. - * A `require`-based load also sidesteps the package's `exports` gate, which - * only permits `.`, `./contract`, and `./rpc`. - */ - -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ -import * as path from "node:path"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const real: any = require( - // __dirname is /test/__mocks__/@stellar, so the repo root is two levels - // up ("../..") — three levels overshoots the repo and makes `real` undefined, - // which surfaces far away as "Cannot read properties of undefined (reading - // 'Server')" inside every e2e suite that boots the Nest app. - path.join(__dirname, "..", "..", "..", "node_modules", "@stellar", "stellar-sdk", "lib", "index.js"), -); - -const mockServer = { - getHealth: jest.fn().mockResolvedValue({ status: "ok" }), - getLatestLedger: jest.fn().mockResolvedValue({ sequence: 1 }), - getNetwork: jest.fn().mockResolvedValue({ passphrase: "test" }), - getAccount: jest.fn().mockResolvedValue({ id: "test", sequenceNumber: () => "0" }), - getEvents: jest.fn().mockResolvedValue({ events: [], latestLedger: 1 }), - getFeeStats: jest.fn().mockResolvedValue({ - sorobanInclusionFee: { - min: "100", - mode: "100", - p10: "100", - p20: "100", - p30: "100", - p40: "100", - p50: "100", - p60: "100", - p70: "100", - p80: "100", - p90: "100", - p95: "100", - p99: "100", - max: "100", - }, - }), - simulateTransaction: jest.fn().mockResolvedValue({ minResourceFee: "100" }), - prepareTransaction: jest.fn().mockImplementation((tx: unknown) => tx), - sendTransaction: jest.fn().mockResolvedValue({ status: "SUCCESS", hash: "mock-hash" }), -}; - -const mockServerClass = jest.fn().mockImplementation(() => mockServer); - -/** - * Network stub. `Api` is spread from the real module so type guards such as - * `SorobanRpc.Api.isSimulationError` keep working exactly as in production. - */ -export const SorobanRpc = { - ...real.SorobanRpc, - Server: mockServerClass, - Api: { - ...real.SorobanRpc?.Api, - isSimulationError: (response: unknown): boolean => - Boolean( - response && - typeof response === "object" && - "error" in (response as Record) && - (response as Record).error != null, - ), - }, -}; - -// ── Genuine SDK re-exports ─────────────────────────────────────────────────── -// Everything below is the real implementation, re-exported explicitly rather -// than via `export *` so that the star-export does not shadow the stubbed -// `SorobanRpc` above and so each name is individually type-checked. - -export const Keypair = real.Keypair; -export const Networks = real.Networks; -export const StrKey = real.StrKey; -export const Address = real.Address; -export const Asset = real.Asset; -export const Horizon = real.Horizon; -export const Contract = real.Contract; -export const Account = real.Account; -export const Operation = real.Operation; -export const Transaction = real.Transaction; -export const FeeBumpTransaction = real.FeeBumpTransaction; -export const TransactionBuilder = real.TransactionBuilder; -export const xdr = real.xdr; -export const nativeToScVal = real.nativeToScVal; -export const scValToNative = real.scValToNative; -export const BASE_FEE = real.BASE_FEE; -// Namespaced clients used by production code (e.g. TreasuryService builds -// `new StellarSdk.Horizon.Server(...)`). Without these the mock leaves -// `StellarSdk.Horizon` undefined and every e2e suite that boots the app fails. -export const Horizon = real.Horizon; -export const Utils = real.Utils; -export const Config = real.Config; -export const MuxedAccount = real.MuxedAccount; -export const hash = real.hash; -export const Memo = real.Memo; -export const Timepoint = real.Timepoint; -export const SorobanDataBuilder = real.SorobanDataBuilder; -export const authorizeEntry = real.authorizeEntry; -export const decodeAddressToScVal = real.decodeAddressToScVal; -export const encodeAddressToScVal = real.encodeAddressToScVal; diff --git a/test/__mocks__/nestjs-schedule.ts b/test/__mocks__/nestjs-schedule.ts new file mode 100644 index 0000000..d1758c3 --- /dev/null +++ b/test/__mocks__/nestjs-schedule.ts @@ -0,0 +1,24 @@ +/** Jest stand-in for the ESM-only @nestjs/schedule package. */ +export const CronExpression = { + EVERY_MINUTE: "* * * * *", + EVERY_5_MINUTES: "*/5 * * * *", + EVERY_DAY_AT_MIDNIGHT: "0 0 * * *", +}; + +export function Cron(): MethodDecorator { + return () => undefined; +} + +export function Interval(): MethodDecorator { + return () => undefined; +} + +export function Timeout(): MethodDecorator { + return () => undefined; +} + +export class ScheduleModule { + static forRoot(): { module: typeof ScheduleModule } { + return { module: ScheduleModule }; + } +} diff --git a/test/audit-trail.e2e-spec.ts b/test/audit-trail.e2e-spec.ts index 0e00830..b84244f 100644 --- a/test/audit-trail.e2e-spec.ts +++ b/test/audit-trail.e2e-spec.ts @@ -22,7 +22,7 @@ const validCreateBody = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; describe("Audit trail e2e (#217)", () => { diff --git a/test/body-size.e2e-spec.ts b/test/body-size.e2e-spec.ts index 3ee95d1..2d2dd20 100644 --- a/test/body-size.e2e-spec.ts +++ b/test/body-size.e2e-spec.ts @@ -32,7 +32,7 @@ describe("Body size limit (e2e)", () => { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; await request(app.getHttpServer()) @@ -52,7 +52,7 @@ describe("Body size limit (e2e)", () => { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; await request(app.getHttpServer()) diff --git a/test/cors.e2e-spec.ts b/test/cors.e2e-spec.ts index 5d8eb76..e69de29 100644 --- a/test/cors.e2e-spec.ts +++ b/test/cors.e2e-spec.ts @@ -1,192 +0,0 @@ -/** - * e2e test: CORS_ORIGIN config is wired into the app. - * - * Verifies that Access-Control-Allow-Origin reflects the CORS_ORIGIN env var - * rather than being absent (NestJS default) or hard-coded. - */ -import { INestApplication, ValidationPipe } from "@nestjs/common"; -import { Test } from "@nestjs/testing"; -import { WsAdapter } from "@nestjs/platform-ws"; -import { ConfigService } from "@nestjs/config"; -import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import helmet from "helmet"; -import request from "supertest"; -import { AppModule } from "../src/app.module"; -import { AppConfig } from "../src/config/configuration"; -import { HttpExceptionFilter } from "../src/common/http-exception.filter"; -import { PrismaService } from "../src/prisma/prisma.service"; -import { MockPrismaService } from "./utils/create-test-app"; - -async function createAppWithOrigin(origin: string): Promise { - // Override CORS_ORIGIN before the module initializes. - process.env.CORS_ORIGIN = origin; - - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideProvider(PrismaService) - .useClass(MockPrismaService) - .compile(); - - const app = moduleRef.createNestApplication(); - app.useWebSocketAdapter(new WsAdapter(app)); - app.useGlobalFilters(new HttpExceptionFilter()); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - - const configService = app.get(ConfigService); - const corsOrigin = configService.get("corsOrigin", { infer: true }); - app.enableCors({ origin: corsOrigin }); - - await app.init(); - return app; -} - -async function createAppWithSecurityHeaders(nodeEnv = "development"): Promise { - const previousNodeEnv = process.env.NODE_ENV; - const previousAllowLocalSigner = process.env.ALLOW_LOCAL_SIGNER_IN_PROD; - process.env.NODE_ENV = nodeEnv; - // The app refuses to boot in production with the local keypair signer, which - // is the right production guard but unrelated to the security headers and - // Swagger-visibility behaviour this suite exercises. Opt in for the boot only. - if (nodeEnv === "production") process.env.ALLOW_LOCAL_SIGNER_IN_PROD = "true"; - - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideProvider(PrismaService) - .useClass(MockPrismaService) - .compile(); - - const app = moduleRef.createNestApplication(); - app.set("trust proxy", 1); - app.use((req, res, next) => { - const isDocsRequest = req.path === "/docs" || req.path === "/docs-json"; - if (isDocsRequest) { - res.setHeader("Cache-Control", "no-store"); - } - next(); - }); - app.use( - helmet({ - hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, - frameguard: { action: "deny" }, - referrerPolicy: { policy: "strict-origin-when-cross-origin" }, - noSniff: true, - }), - ); - app.useWebSocketAdapter(new WsAdapter(app)); - app.useGlobalFilters(new HttpExceptionFilter()); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - - // Mirror main.ts: serve the OpenAPI document, and only mount the Swagger UI - // outside production. Without this the /docs and /docs-json assertions below - // would 404 regardless of the security headers under test. - const swaggerConfig = new DocumentBuilder() - .setTitle("Vortex Backend") - .setDescription("Intent relay API + WebSocket feed for Vortex Protocol") - .setVersion("0.1.0") - .build(); - const document = SwaggerModule.createDocument(app, swaggerConfig); - if (nodeEnv !== "production") { - SwaggerModule.setup("docs", app, document); - } - - await app.init(); - - process.env.NODE_ENV = previousNodeEnv; - if (previousAllowLocalSigner === undefined) { - delete process.env.ALLOW_LOCAL_SIGNER_IN_PROD; - } else { - process.env.ALLOW_LOCAL_SIGNER_IN_PROD = previousAllowLocalSigner; - } - return app; -} - -describe("CORS (e2e)", () => { - afterEach(() => { - // Restore so other tests are not affected. - delete process.env.CORS_ORIGIN; - }); - - it("responds with Access-Control-Allow-Origin: * when CORS_ORIGIN is *", async () => { - const app = await createAppWithOrigin("*"); - try { - const res = await request(app.getHttpServer()) - .get("/health") - .set("Origin", "http://example.com") - .expect(200); - - expect(res.headers["access-control-allow-origin"]).toBe("*"); - } finally { - await app.close(); - } - }); - - it("reflects a specific CORS_ORIGIN in the response header", async () => { - const allowedOrigin = "https://app.vortex.finance"; - const app = await createAppWithOrigin(allowedOrigin); - try { - const res = await request(app.getHttpServer()) - .get("/health") - .set("Origin", allowedOrigin) - .expect(200); - - expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); - } finally { - await app.close(); - } - }); - - it("does NOT echo back an origin that is not in CORS_ORIGIN", async () => { - const app = await createAppWithOrigin("https://app.vortex.finance"); - try { - const res = await request(app.getHttpServer()) - .get("/health") - .set("Origin", "https://evil.example.com") - .expect(200); - - // When origin is a specific string and the request Origin doesn't match, - // express-cors either omits the header or sets it to the allowed origin. - // Either way it must NOT be the attacker's origin. - expect(res.headers["access-control-allow-origin"]).not.toBe("https://evil.example.com"); - } finally { - await app.close(); - } - }); - - it("adds HSTS and nosniff headers behind a trusted proxy", async () => { - const app = await createAppWithSecurityHeaders(); - try { - const res = await request(app.getHttpServer()) - .get("/health") - .set("X-Forwarded-Proto", "https") - .expect(200); - - expect(res.headers["strict-transport-security"]).toContain("max-age=31536000"); - expect(res.headers["x-content-type-options"]).toBe("nosniff"); - expect(res.headers["x-frame-options"]).toBe("DENY"); - expect(res.headers["referrer-policy"]).toBe("strict-origin-when-cross-origin"); - } finally { - await app.close(); - } - }); - - it("marks OpenAPI docs as no-store so they are not cached", async () => { - const app = await createAppWithSecurityHeaders(); - try { - const res = await request(app.getHttpServer()).get("/docs-json").expect(200); - expect(res.headers["cache-control"]).toContain("no-store"); - } finally { - await app.close(); - } - }); - - it("disables Swagger UI by default in production", async () => { - const app = await createAppWithSecurityHeaders("production"); - try { - await request(app.getHttpServer()).get("/docs").expect(404); - } finally { - await app.close(); - } - }); -}); diff --git a/test/dos-limits.e2e-spec.ts b/test/dos-limits.e2e-spec.ts index 1d92bb4..e69de29 100644 --- a/test/dos-limits.e2e-spec.ts +++ b/test/dos-limits.e2e-spec.ts @@ -1,297 +0,0 @@ -/** - * DoS / resource-exhaustion test suite (issue #476). - * - * Tests every limit added by the issue #476 hardening pass: - * - * 1. Body size cap — POST > 10 KB → 413 - * 2. JSON depth cap — body nesting > 10 deep → 400 - * 3. Batch size cap — POST /batch with > 100 IDs → 400 - * 4. Pagination cap — GET /intents?limit=101 → 400 - * 5. WS subscribe chain-filter cap — chains array > 20 → subscribe_rejected - * 6. WS subscription-count cap — > 10 subscribe messages → subscribe_rejected - * - * For each limit the test covers: - * - Just-under (or at) the limit → passes (2xx / subscribed) - * - Just-over the limit → rejected (4xx / subscribe_rejected) - */ -import { INestApplication } from "@nestjs/common"; -import request from "supertest"; -import WebSocket from "ws"; -import { createTestApp } from "./utils/create-test-app"; -import { - BATCH_LOOKUP_MAX_IDS, - JSON_MAX_DEPTH, - LIST_MAX_LIMIT, - WS_MAX_FILTER_CHAINS, - WS_MAX_SUBSCRIPTIONS_PER_CONNECTION, -} from "../src/config/limits.config"; - -/** Build a nested JSON object `depth` levels deep with a leaf value. */ -function buildNestedObject(depth: number, leaf: unknown = "leaf"): unknown { - if (depth <= 0) return leaf; - return { a: buildNestedObject(depth - 1, leaf) }; -} - -/** Wait for a WS message matching a predicate and resolve with parsed data. */ -function waitForMessage( - ws: WebSocket, - predicate: (msg: Record) => boolean, - timeoutMs = 3000, -): Promise> { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("waitForMessage timeout")), timeoutMs); - - ws.on("message", (data) => { - const msg = JSON.parse(data.toString()) as Record; - if (predicate(msg)) { - clearTimeout(timer); - resolve(msg); - } - }); - - ws.once("error", (err) => { - clearTimeout(timer); - reject(err); - }); - }); -} - -/** Open a WS connection, wait for the initial "connected" + "snapshot" pair, - * then return the client. Rejects if either message does not arrive. */ -async function openWs(port: number): Promise { - const ws = new WebSocket(`ws://localhost:${port}/ws`); - - await new Promise((resolve, reject) => { - ws.once("error", reject); - ws.once("open", () => { - // Drain the initial connected+snapshot before returning. - let seen = 0; - const handler = () => { - seen++; - if (seen >= 2) { - ws.removeListener("message", handler); - resolve(); - } - }; - ws.on("message", handler); - }); - }); - - return ws; -} - -// ───────────────────────────────────────────────────────────────────────────── - -describe("DoS / resource-exhaustion limits (issue #476)", () => { - let app: INestApplication; - let httpServer: ReturnType; - - beforeAll(async () => { - app = await createTestApp(); - // Bind an ephemeral port: the WS sections below read the real port off - // httpServer.address(), which is null for an initialised-but-not-listening - // app. Port 0 lets the OS pick, so parallel suites never collide. - await app.listen(0); - httpServer = app.getHttpServer(); - }); - - afterAll(async () => { - await app.close(); - }); - - // ── 1. Body size cap ───────────────────────────────────────────────────── - describe("1. Body size cap (10 KB)", () => { - it("rejects a payload > 10 KB with 413", async () => { - const oversized = { - srcAmount: "1".padEnd(11 * 1024, "0"), // clearly >10 KB - }; - await request(httpServer) - .post("/api/v1/intents") - .send(oversized) - .expect(413); - }); - - it("accepts a well-formed payload within 10 KB with 201/400 (not 413)", async () => { - // A valid-shaped but short body — validation may still 400 it for missing - // required fields, but it must NOT 413 (body was accepted by the size gate). - const small = { srcAmount: "1000000" }; - const res = await request(httpServer).post("/api/v1/intents").send(small); - expect(res.status).not.toBe(413); - }); - }); - - // ── 2. JSON depth cap ───────────────────────────────────────────────────── - describe(`2. JSON depth cap (max ${JSON_MAX_DEPTH})`, () => { - it(`rejects a body nested ${JSON_MAX_DEPTH + 1} levels deep with 400`, async () => { - const tooDeep = buildNestedObject(JSON_MAX_DEPTH + 1); - await request(httpServer) - .post("/api/v1/intents") - .send(tooDeep) - .expect(400); - }); - - it(`accepts a body nested exactly ${JSON_MAX_DEPTH} levels deep (not 400 from depth check)`, async () => { - // Build an object exactly at the depth limit — it should pass the depth - // guard even if it still fails DTO validation. - const atLimit = buildNestedObject(JSON_MAX_DEPTH); - const res = await request(httpServer).post("/api/v1/intents").send(atLimit); - // Must not be 400 due to depth — DTO validation may reject it for other reasons. - // We specifically check the response body does not mention "nesting depth". - if (res.status === 400) { - const body = res.body as { message?: string | string[] }; - const msgs = Array.isArray(body.message) - ? body.message.join(" ") - : body.message ?? ""; - expect(msgs).not.toContain("nesting depth"); - } - }); - - it("rejects a body nested 100 levels deep (well over the cap) with 400", async () => { - const wayTooDeep = buildNestedObject(100); - const res = await request(httpServer) - .post("/api/v1/intents") - .send(wayTooDeep) - .expect(400); - expect((res.body as { message?: string }).message).toMatch(/nesting depth/i); - }); - }); - - // ── 3. Batch size cap ──────────────────────────────────────────────────── - describe(`3. Batch size cap (max ${BATCH_LOOKUP_MAX_IDS} IDs)`, () => { - it(`rejects ${BATCH_LOOKUP_MAX_IDS + 1} IDs with 400`, async () => { - const oversized = { - intentIds: Array.from({ length: BATCH_LOOKUP_MAX_IDS + 1 }, (_, i) => `id-${i}`), - }; - await request(httpServer) - .post("/api/v1/intents/batch") - .send(oversized) - .expect(400); - }); - - it(`accepts exactly ${BATCH_LOOKUP_MAX_IDS} IDs with 200`, async () => { - const atLimit = { - intentIds: Array.from({ length: BATCH_LOOKUP_MAX_IDS }, (_, i) => `id-${i}`), - }; - // 200 expected — none of the IDs exist so the response will be an empty array. - await request(httpServer) - .post("/api/v1/intents/batch") - .send(atLimit) - .expect(200); - }); - - it("accepts 1 ID with 200", async () => { - await request(httpServer) - .post("/api/v1/intents/batch") - .send({ intentIds: ["does-not-exist"] }) - .expect(200); - }); - - it("rejects a non-array intentIds with 400", async () => { - await request(httpServer) - .post("/api/v1/intents/batch") - .send({ intentIds: "single-string-not-array" }) - .expect(400); - }); - }); - - // ── 4. Pagination limit cap ─────────────────────────────────────────────── - describe(`4. Pagination limit cap (max ${LIST_MAX_LIMIT})`, () => { - it(`rejects limit=${LIST_MAX_LIMIT + 1} with 400`, async () => { - await request(httpServer) - .get("/api/v1/intents") - .query({ limit: LIST_MAX_LIMIT + 1 }) - .expect(400); - }); - - it(`accepts limit=${LIST_MAX_LIMIT} with 200`, async () => { - await request(httpServer) - .get("/api/v1/intents") - .query({ limit: LIST_MAX_LIMIT }) - .expect(200); - }); - - it("accepts limit=1 with 200", async () => { - await request(httpServer).get("/api/v1/intents").query({ limit: 1 }).expect(200); - }); - - it("rejects limit=0 with 400", async () => { - await request(httpServer).get("/api/v1/intents").query({ limit: 0 }).expect(400); - }); - }); - - // ── 5. WS subscribe chain-filter cap ───────────────────────────────────── - describe(`5. WS subscribe chain-filter cap (max ${WS_MAX_FILTER_CHAINS} chains)`, () => { - it(`rejects a subscribe message with ${WS_MAX_FILTER_CHAINS + 1} chains`, (done) => { - const port = (httpServer.address() as { port: number }).port; - - openWs(port).then((ws) => { - const tooManyChains = Array.from( - { length: WS_MAX_FILTER_CHAINS + 1 }, - (_, i) => `chain-${i}`, - ); - - ws.send(JSON.stringify({ type: "subscribe", chains: tooManyChains })); - - waitForMessage(ws, (m) => m.type === "subscribe_rejected") - .then((msg) => { - expect(msg.reason).toBeDefined(); - ws.close(); - done(); - }) - .catch(done); - }).catch(done); - }); - - it(`accepts a subscribe message with exactly ${WS_MAX_FILTER_CHAINS} chains`, (done) => { - const port = (httpServer.address() as { port: number }).port; - - openWs(port).then((ws) => { - // Fill with the real supported chains (only 7 exist, so pad with repeats - // to reach exactly WS_MAX_FILTER_CHAINS — they will be validated against - // SUPPORTED_CHAINS and only valid ones kept, but the message itself should - // not be rejected at the length gate). - const chains = Array.from({ length: WS_MAX_FILTER_CHAINS }, (_, i) => - i % 2 === 0 ? "stellar" : "ethereum", - ); - - ws.send(JSON.stringify({ type: "subscribe", chains })); - - waitForMessage(ws, (m) => m.type === "subscribed" || m.type === "subscribe_rejected") - .then((msg) => { - // Should be "subscribed" (length gate passed), not "subscribe_rejected" - expect(msg.type).toBe("subscribed"); - ws.close(); - done(); - }) - .catch(done); - }).catch(done); - }); - }); - - // ── 6. WS subscription-count cap ───────────────────────────────────────── - describe(`6. WS subscription-count cap (max ${WS_MAX_SUBSCRIPTIONS_PER_CONNECTION} per connection)`, () => { - it(`rejects the ${WS_MAX_SUBSCRIPTIONS_PER_CONNECTION + 1}th subscribe message`, (done) => { - const port = (httpServer.address() as { port: number }).port; - - openWs(port).then(async (ws) => { - // Send exactly WS_MAX_SUBSCRIPTIONS_PER_CONNECTION subscribe messages; - // all should succeed. - for (let i = 0; i < WS_MAX_SUBSCRIPTIONS_PER_CONNECTION; i++) { - ws.send(JSON.stringify({ type: "subscribe", chains: ["stellar"] })); - await waitForMessage(ws, (m) => m.type === "subscribed" || m.type === "subscribe_rejected"); - } - - // The next one must be rejected. - ws.send(JSON.stringify({ type: "subscribe", chains: ["stellar"] })); - - waitForMessage(ws, (m) => m.type === "subscribe_rejected") - .then((msg) => { - expect(msg.reason).toBeDefined(); - ws.close(); - done(); - }) - .catch(done); - }).catch(done); - }, 15_000); - }); -}); diff --git a/test/intent-expiry.e2e-spec.ts b/test/intent-expiry.e2e-spec.ts index 67f1608..2053173 100644 --- a/test/intent-expiry.e2e-spec.ts +++ b/test/intent-expiry.e2e-spec.ts @@ -28,7 +28,7 @@ const BASE_INTENT = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "490000", + minDstAmount: "4950000", }; /** Helper — open a WS client and collect messages until the timeout. */ diff --git a/test/intent-lifecycle.e2e-spec.ts b/test/intent-lifecycle.e2e-spec.ts index fda287c..820f746 100644 --- a/test/intent-lifecycle.e2e-spec.ts +++ b/test/intent-lifecycle.e2e-spec.ts @@ -43,7 +43,7 @@ const BASE_INTENT = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "1980000", + minDstAmount: "19800000", }; /** Shape IntentsService.create() expects (already-resolved token objects). */ @@ -125,7 +125,7 @@ describe("Intent lifecycle e2e (create → accept → fill)", () => { const betaFillSig = sign(BETA_KP, buildFillMessage(intentId, BETA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${intentId}/fill`) - .send({ solver: BETA_KP.publicKey(), fillAmount: "1990000", signature: betaFillSig }) + .send({ solver: BETA_KP.publicKey(), fillAmount: "19900000", signature: betaFillSig }) .expect(403); // State must still be accepted after all guard rejections @@ -142,7 +142,7 @@ describe("Intent lifecycle e2e (create → accept → fill)", () => { .post(`/api/v1/intents/${intentId}/fill`) .send({ solver: ALPHA_KP.publicKey(), - fillAmount: "1990000", + fillAmount: "19900000", txHash: "lifecycle-e2e-tx-hash", signature: alphaFillSig, }) @@ -150,7 +150,7 @@ describe("Intent lifecycle e2e (create → accept → fill)", () => { expect(fillRes.body.state).toBe("filled"); expect(fillRes.body.solver).toBe(ALPHA_KP.publicKey()); - expect(fillRes.body.fillAmount).toBe("1990000"); + expect(fillRes.body.fillAmount).toBe("19900000"); expect(fillRes.body.txHash).toBe("lifecycle-e2e-tx-hash"); expect(typeof fillRes.body.filledAt).toBe("number"); @@ -160,7 +160,7 @@ describe("Intent lifecycle e2e (create → accept → fill)", () => { .expect(200); expect(getAfterFill.body.state).toBe("filled"); expect(getAfterFill.body.solver).toBe(ALPHA_KP.publicKey()); - expect(getAfterFill.body.fillAmount).toBe("1990000"); + expect(getAfterFill.body.fillAmount).toBe("19900000"); expect(getAfterFill.body.txHash).toBe("lifecycle-e2e-tx-hash"); expect(typeof getAfterFill.body.filledAt).toBe("number"); @@ -168,7 +168,7 @@ describe("Intent lifecycle e2e (create → accept → fill)", () => { const refillSig = sign(ALPHA_KP, buildFillMessage(intentId, ALPHA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${intentId}/fill`) - .send({ solver: ALPHA_KP.publicKey(), fillAmount: "1990000", signature: refillSig }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "19900000", signature: refillSig }) .expect(409); }); diff --git a/test/intents.e2e-spec.ts b/test/intents.e2e-spec.ts index 4c7fb2a..b0f7fac 100644 --- a/test/intents.e2e-spec.ts +++ b/test/intents.e2e-spec.ts @@ -29,7 +29,7 @@ const validCreateBody = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; describe("IntentsController (e2e)", () => { @@ -152,7 +152,7 @@ describe("IntentsController (e2e)", () => { const betaFillSig = sign(BETA_KP, buildFillMessage(created.intentId, BETA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: BETA_KP.publicKey(), fillAmount: "995000", signature: betaFillSig }) + .send({ solver: BETA_KP.publicKey(), fillAmount: "9950000", signature: betaFillSig }) .expect(403); // correct solver fills @@ -161,13 +161,13 @@ describe("IntentsController (e2e)", () => { .post(`/api/v1/intents/${created.intentId}/fill`) .send({ solver: ALPHA_KP.publicKey(), - fillAmount: "995000", + fillAmount: "9950000", txHash: "e2e-hash", signature: fillSig, }) .expect(201); expect(filled.body.state).toBe("filled"); - expect(filled.body.fillAmount).toBe("995000"); + expect(filled.body.fillAmount).toBe("9950000"); expect(filled.body.txHash).toBe("e2e-hash"); }); @@ -266,7 +266,7 @@ describe("IntentsController (e2e)", () => { const fillSig = sign(ALPHA_KP, buildFillMessage(created.intentId, ALPHA_KP.publicKey())); const res = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: ALPHA_KP.publicKey(), fillAmount: "995000", txHash: "e2e-hash", signature: fillSig }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "9950000", txHash: "e2e-hash", signature: fillSig }) .expect(400); expect(res.body.error).toBe("Data integrity error: intent minDstAmount is not a valid integer"); expect(res.body.intentId).toBe(created.intentId); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 4416d59..e69de29 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,13 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": "..", - "testEnvironment": "node", - "testRegex": "test/.*\\.(e2e-spec|test)\\.ts$", - "transform": { - "^.+\\.ts$": ["ts-jest", { "diagnostics": false }] - }, - "moduleNameMapper": { - "^@stellar/stellar-sdk$": "/test/__mocks__/@stellar/stellar-sdk.ts", - "^@nestjs/schedule$": "/test/__mocks__/@nestjs/schedule.ts" - } -} diff --git a/test/load/concurrent-accept.test.ts b/test/load/concurrent-accept.test.ts index be3469b..e69de29 100644 --- a/test/load/concurrent-accept.test.ts +++ b/test/load/concurrent-accept.test.ts @@ -1,282 +0,0 @@ -import { INestApplication } from "@nestjs/common"; -import request from "supertest"; -import { Keypair } from "@stellar/stellar-sdk"; -import { createTestApp } from "../utils/create-test-app"; -import { InMemoryIntentsRepository, isVersionConflict } from "../../src/intents/intents.repository"; -import { IntentsService } from "../../src/intents/intents.service"; -import { IntentsSweeperService } from "../../src/intents/intents-sweeper.service"; -import { SolverRegistryService } from "../../src/soroban/solver-registry.service"; -import { SEED_SOLVER_KEYPAIRS } from "../../src/solvers/solvers.seed"; -import { buildAcceptMessage, buildCancelMessage, buildFillMessage } from "../../src/common/stellar-signature"; - -const SOLVERS = [SEED_SOLVER_KEYPAIRS.ALPHA, SEED_SOLVER_KEYPAIRS.BETA, SEED_SOLVER_KEYPAIRS.GAMMA]; -const USER = Keypair.random(); - -function sign(kp: Keypair, message: string): string { - return kp.sign(Buffer.from(message, "utf8")).toString("base64"); -} - -const validCreateBody = { - user: USER.publicKey(), - srcChain: "ethereum", - srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - srcTokenSymbol: "USDC", - srcTokenDecimals: 6, - srcAmount: "1000000", - dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - dstTokenSymbol: "USDC", - dstTokenDecimals: 7, - minDstAmount: "990000", -}; - -describe("Concurrent accept / fill race load test", () => { - let app: INestApplication; - let baseUrl: string; - - beforeAll(async () => { - app = await createTestApp(); - // Bind once: handing supertest an unbound server makes it open a new - // ephemeral listener per request, which resets under 20-way concurrency. - await app.listen(0, "127.0.0.1"); - baseUrl = (await app.getUrl()).replace("[::1]", "127.0.0.1"); - }); - - afterAll(async () => { - await app.close(); - }); - - const http = () => request(baseUrl); - - async function createIntent(): Promise { - const res = await http().post("/api/v1/intents").send(validCreateBody).expect(201); - return res.body.intentId as string; - } - - function accept(intentId: string, solver: Keypair) { - return http() - .post(`/api/v1/intents/${intentId}/accept`) - .send({ solver: solver.publicKey(), signature: sign(solver, buildAcceptMessage(intentId, solver.publicKey())) }); - } - - function fill(intentId: string, solver: Keypair) { - return http() - .post(`/api/v1/intents/${intentId}/fill`) - .send({ - solver: solver.publicKey(), - fillAmount: "995000", - txHash: `tx-${Math.random().toString(16).slice(2)}`, - signature: sign(solver, buildFillMessage(intentId, solver.publicKey())), - }); - } - - function cancel(intentId: string) { - return http() - .post(`/api/v1/intents/${intentId}/cancel`) - .send({ user: USER.publicKey(), signature: sign(USER, buildCancelMessage(intentId)) }); - } - - it("only one solver wins when N concurrent accept() calls race on the same intent", async () => { - const intentId = await createIntent(); - - const results = await Promise.all(Array.from({ length: 20 }, (_, i) => accept(intentId, SOLVERS[i % SOLVERS.length]))); - - expect(results.filter((r) => r.status === 201)).toHaveLength(1); - expect(results.filter((r) => r.status !== 201).every((r) => r.status === 409)).toBe(true); - - const intent = (await http().get(`/api/v1/intents/${intentId}`).expect(200)).body; - expect(intent.state).toBe("accepted"); - expect(SOLVERS.map((s) => s.publicKey())).toContain(intent.solver); - }); - - it("only one fill wins when N concurrent fill() calls race on the same accepted intent", async () => { - const intentId = await createIntent(); - await accept(intentId, SEED_SOLVER_KEYPAIRS.ALPHA).expect(201); - - const results = await Promise.all(Array.from({ length: 20 }, () => fill(intentId, SEED_SOLVER_KEYPAIRS.ALPHA))); - - expect(results.filter((r) => r.status === 201)).toHaveLength(1); - const intent = (await http().get(`/api/v1/intents/${intentId}`).expect(200)).body; - expect(intent.state).toBe("filled"); - expect(intent.fillAmount).toBe("995000"); - }); - - it("mixed solvers racing for different intents all resolve with exactly one winner each", async () => { - const intentIds = await Promise.all(Array.from({ length: 3 }, () => createIntent())); - - const results = await Promise.all(intentIds.map((id, i) => accept(id, SOLVERS[i % SOLVERS.length]))); - - expect(results.every((r) => r.status === 201)).toBe(true); - for (const id of intentIds) { - expect((await http().get(`/api/v1/intents/${id}`).expect(200)).body.state).toBe("accepted"); - } - }); - - it("unit-level: acceptIfOpen rejects concurrent calls on the same intent", () => { - const repo = new InMemoryIntentsRepository(); - const [open] = repo.findByState("open"); - - const results = Array.from({ length: 50 }, (_, i) => - repo.acceptIfOpen(open.intentId, `SOLVER_${i}`, Math.floor(Date.now() / 1000) + 300), - ); - - const winners = results.filter((r) => r !== null); - expect(winners).toHaveLength(1); - expect(winners[0]).toMatchObject({ state: "accepted", version: 1 }); - }); - - it("unit-level: fillIfAccepted rejects concurrent calls on the same intent", () => { - const repo = new InMemoryIntentsRepository(); - const [open] = repo.findByState("open"); - - repo.acceptIfOpen(open.intentId, "SOLVER_X", Math.floor(Date.now() / 1000) + 300); - - const results = Array.from({ length: 50 }, () => - repo.fillIfAccepted(open.intentId, "SOLVER_X", { - fillAmount: "995000", - txHash: "race-hash", - filledAt: Math.floor(Date.now() / 1000), - }), - ); - - const winners = results.filter((r) => r !== null); - expect(winners).toHaveLength(1); - expect(winners[0]).toMatchObject({ state: "filled", version: 2 }); - }); - - it("race inventory #473: accept past deadline loses even when it beats the sweeper to the write", async () => { - const repo = new InMemoryIntentsRepository(); - const [open] = repo.findByState("open"); - const pastDeadline = Math.floor(Date.now() / 1000) - 10; - const expired = { ...open, deadline: pastDeadline }; - // Simulate an intent whose deadline elapsed before the accept write lands. - repo.save({ ...expired }); - const now = Math.floor(Date.now() / 1000); - const result = await repo.acceptIfOpen(open.intentId, "SOLVER_LATE", now + 300, now); - expect(result).toBeNull(); - }); - - it("race inventory #473: fill past the accept-extended deadline loses (sweeper slash wins)", async () => { - const repo = new InMemoryIntentsRepository(); - const [open] = repo.findByState("open"); - const now = Math.floor(Date.now() / 1000); - await repo.acceptIfOpen(open.intentId, "SOLVER_X", now + 1, now - 100); - // Advance past the fill window before the fill write lands. - const late = await repo.fillIfAccepted( - open.intentId, - "SOLVER_X", - { fillAmount: "995000", txHash: "late", filledAt: now + 60 }, - now + 60, - ); - expect(late).toBeNull(); - }); - - it("race inventory #473: cancel vs accept — exactly one terminal path wins", async () => { - const repo = new InMemoryIntentsRepository(); - const [open] = repo.findByState("open"); - const now = Math.floor(Date.now() / 1000); - const accepted = await repo.acceptIfOpen(open.intentId, "SOLVER_RACE", now + 300, now); - const cancelled = await repo.cancelIfOpen(open.intentId); - // Exactly one of the two conditional writes may succeed. - expect(Number(accepted !== null) + Number(cancelled !== null)).toBeLessThanOrEqual(1); - }); - - // ── Issue #405: optimistic concurrency, ETag / If-Match, zero lost updates ── - - describe("optimistic concurrency (issue #405)", () => { - it("exposes the version as an ETag and advances it on every mutation", async () => { - const intentId = await createIntent(); - - const read = await http().get(`/api/v1/intents/${intentId}`).expect(200); - expect(read.headers.etag).toBe('"0"'); - expect(read.body.version).toBe(0); - - const accepted = await accept(intentId, SEED_SOLVER_KEYPAIRS.ALPHA).set("If-Match", '"0"').expect(201); - expect(accepted.headers.etag).toBe('"1"'); - - const filled = await fill(intentId, SEED_SOLVER_KEYPAIRS.ALPHA).set("If-Match", '"1"').expect(201); - expect(filled.headers.etag).toBe('"2"'); - }); - - it("rejects a stale If-Match with 412 and leaves the intent untouched", async () => { - const intentId = await createIntent(); - await http().post(`/api/v1/intents/${intentId}/requote`).expect(201); // version 0 → 1 - - const res = await cancel(intentId).set("If-Match", '"0"').expect(412); - expect(res.body).toMatchObject({ currentVersion: 1, currentETag: '"1"' }); - expect((await http().get(`/api/v1/intents/${intentId}`)).body.state).toBe("open"); - }); - - it("rejects a malformed If-Match with 400", async () => { - const intentId = await createIntent(); - await cancel(intentId).set("If-Match", "not-an-etag").expect(400); - }); - - it("lets exactly one of N clients holding the same ETag win; the rest get 412 or 409", async () => { - const intentId = await createIntent(); - const { etag } = (await http().get(`/api/v1/intents/${intentId}`)).headers; - - const results = await Promise.all( - Array.from({ length: 20 }, (_, i) => accept(intentId, SOLVERS[i % SOLVERS.length]).set("If-Match", etag)), - ); - - expect(results.filter((r) => r.status === 201)).toHaveLength(1); - expect(results.filter((r) => r.status !== 201).every((r) => [409, 412].includes(r.status))).toBe(true); - }); - - it("loses zero updates when N writers do read-modify-write with bounded retries", async () => { - const intents = app.get(IntentsService); - const intentId = await createIntent(); - const writers = 25; - - const results = await Promise.all( - Array.from({ length: writers }, () => - intents.mutateWithRetry( - intentId, - (current) => - intents.update( - intentId, - { quotedDstAmount: String(Number(current.quotedDstAmount ?? "0") + 1) }, - current.version, - ), - writers, // enough budget for every writer to eventually win - ), - ), - ); - - expect(results.some(isVersionConflict)).toBe(false); - const final = (await http().get(`/api/v1/intents/${intentId}`).expect(200)).body; - expect(final.quotedDstAmount).toBe(String(writers)); - expect(final.version).toBe(writers); - }); - - it("a late sweeper never slashes fills that landed after it read the intents", async () => { - const intents = app.get(IntentsService); - const sweeper = app.get(IntentsSweeperService); - const registry = app.get(SolverRegistryService); - const slashSpy = jest.spyOn(registry, "slashSolver"); - - const ids = await Promise.all(Array.from({ length: 10 }, () => createIntent())); - await Promise.all(ids.map((id) => accept(id, SEED_SOLVER_KEYPAIRS.ALPHA).expect(201))); - - // The sweeper's snapshot: every intent accepted and (as far as it knows) - // overdue. Then every fill lands before its writes do. - const past = Math.floor(Date.now() / 1000) - 1; - const snapshot = (await intents.getMany(ids)).map((i) => ({ ...i, deadline: past })); - await Promise.all(ids.map((id) => fill(id, SEED_SOLVER_KEYPAIRS.ALPHA).expect(201))); - - const realGetByState = intents.getByState.bind(intents); - const getByState = jest - .spyOn(intents, "getByState") - .mockImplementation(async (state) => (state === "accepted" ? snapshot : realGetByState(state))); - - const result = await sweeper.sweep(); - getByState.mockRestore(); - - expect(result.slashedCount).toBe(0); - expect(slashSpy).not.toHaveBeenCalledWith(expect.objectContaining({ intentId: expect.stringMatching(ids.join("|")) })); - for (const intent of await intents.getMany(ids)) { - expect(intent.state).toBe("filled"); - } - }); - }); -}); diff --git a/test/load/concurrent-idempotent-create.test.ts b/test/load/concurrent-idempotent-create.test.ts index ece354f..e69de29 100644 --- a/test/load/concurrent-idempotent-create.test.ts +++ b/test/load/concurrent-idempotent-create.test.ts @@ -1,88 +0,0 @@ -import { INestApplication } from "@nestjs/common"; -import request from "supertest"; -import { randomUUID } from "node:crypto"; -import { createTestApp } from "../utils/create-test-app"; - -/** - * Issue #274 — concurrent-retry load test for the idempotency-key path in - * IntentsService.create(). - * - * Mirrors test/load/concurrent-accept.test.ts: fire N simultaneous POST - * /api/v1/intents requests that all carry the *same* idempotencyKey and assert - * that exactly one intent is created and every response points at it. - */ - -const validCreateBody = { - user: "GRACETESTUSER1234567", - srcChain: "ethereum", - srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - srcTokenSymbol: "USDC", - srcTokenDecimals: 6, - srcAmount: "1000000", - dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", - dstTokenSymbol: "USDC", - dstTokenDecimals: 7, - minDstAmount: "990000", -}; - -describe("Concurrent idempotent create race load test", () => { - let app: INestApplication; - let baseUrl: string; - - beforeAll(async () => { - app = await createTestApp(); - // Bind once: an unbound server makes supertest open a listener per - // request, which resets connections under concurrency. - await app.listen(0, "127.0.0.1"); - baseUrl = (await app.getUrl()).replace("[::1]", "127.0.0.1"); - }); - - afterAll(async () => { - await app.close(); - }); - - it("creates exactly one intent when N concurrent create() calls share an idempotencyKey", async () => { - const idempotencyKey = randomUUID(); - const concurrency = 25; - - const results = await Promise.allSettled( - Array.from({ length: concurrency }, () => - request(baseUrl) - .post("/api/v1/intents") - .send({ ...validCreateBody, idempotencyKey }), - ), - ); - - const fulfilled = results.filter( - (r): r is PromiseFulfilledResult => r.status === "fulfilled", - ); - const created = fulfilled.filter((r) => r.value.status === 201); - - // Every accepted response must describe the same single intent. - const intentIds = new Set(created.map((r) => r.value.body.intentId)); - expect(intentIds.size).toBe(1); - - const [intentId] = [...intentIds]; - const listed = ( - await request(baseUrl).get("/api/v1/intents").expect(200) - ).body.intents as Array<{ intentId: string }>; - const matches = listed.filter((i) => i.intentId === intentId); - expect(matches).toHaveLength(1); - }); - - it("still creates distinct intents for concurrent calls with different keys", async () => { - const concurrency = 10; - - const results = await Promise.all( - Array.from({ length: concurrency }, () => - request(baseUrl) - .post("/api/v1/intents") - .send({ ...validCreateBody, idempotencyKey: randomUUID() }) - .expect(201), - ), - ); - - const intentIds = new Set(results.map((r) => r.body.intentId)); - expect(intentIds.size).toBe(concurrency); - }); -}); diff --git a/test/load/ws-broadcast-fanout.test.ts b/test/load/ws-broadcast-fanout.test.ts index 6096d70..e69de29 100644 --- a/test/load/ws-broadcast-fanout.test.ts +++ b/test/load/ws-broadcast-fanout.test.ts @@ -1,281 +0,0 @@ -/** - * Load test: WebSocket broadcast fan-out (#84) - * - * Spins up the real NestJS app on a random port, connects N simultaneous WS - * clients, fires a broadcast, and measures: - * - per-client message delivery latency - * - total fan-out wall-clock time - * - memory growth (heapUsed) before vs after - * - * Run with: - * npx jest --config test/jest-load.json - * or directly: - * npx ts-jest test/load/ws-broadcast-fanout.test.ts - * - * The test does NOT rely on external infrastructure — the server is spun up - * in-process using the standard createTestApp() helper. - */ - -import { INestApplication } from "@nestjs/common"; -import { AddressInfo } from "net"; -import WebSocket from "ws"; -import { createTestApp } from "../utils/create-test-app"; -import { IntentsGateway } from "../../src/intents/intents.gateway"; - -// ── tunables ────────────────────────────────────────────────────────────────── - -/** Subscriber counts to exercise. Each tier runs as a separate test case. */ -const SUBSCRIBER_TIERS = [10, 100, 500]; - -/** - * Hard latency budget per-client (ms). A client is considered "slow" if it - * receives the broadcast more than this many ms after the call to broadcast(). - * Keep generous so the test is not flaky in CI. - */ -const LATENCY_BUDGET_MS = 500; - -/** Maximum allowed heap growth (bytes) across the full fan-out. */ -const HEAP_GROWTH_BUDGET_BYTES = 50 * 1024 * 1024; // 50 MB - -// ── helpers ─────────────────────────────────────────────────────────────────── - -function getWsPort(app: INestApplication): number { - const server = app.getHttpServer() as { address(): AddressInfo | null }; - const addr = server.address(); - if (!addr || typeof addr === "string") throw new Error("could not determine WS port"); - return addr.port; -} - -/** - * Open `count` WebSocket clients against the server, wait until every client - * has received its initial `snapshot` message (meaning the handshake is - * complete and the server has added it to the subscriber set), then resolve - * with the array of open sockets. - * - * Clients are opened in batches: firing all `count` connects in a single tick - * overflows the listener's accept backlog on Windows, which answers the - * overflow with a reset (surfaced here as `ECONNREFUSED`). Waiting for each - * batch's snapshot drains the backlog before the next batch is dialled. - */ -async function openClients( - wsUrl: string, - count: number, - batchSize = 50, -): Promise { - const clients: WebSocket[] = []; - - for (let start = 0; start < count; start += batchSize) { - const size = Math.min(batchSize, count - start); - const batch: WebSocket[] = []; - const ready: Promise[] = []; - - for (let i = 0; i < size; i++) { - const index = start + i; - const ws = new WebSocket(wsUrl); - batch.push(ws); - - ready.push( - new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`client ${index} handshake timeout`)), - 10_000, - ); - - ws.on("error", (err) => { - clearTimeout(timeout); - reject(err); - }); - - ws.on("message", (raw) => { - const msg = JSON.parse(raw.toString()) as { type: string }; - // The server sends "connected" then "snapshot" on every connection. - // We wait for the snapshot so we know the client is fully subscribed. - if (msg.type === "snapshot") { - clearTimeout(timeout); - resolve(); - } - }); - }), - ); - } - - await Promise.all(ready); - clients.push(...batch); - } - - return clients; -} - -/** - * Broadcast a single event and measure the time (ms) from when broadcast() - * returns until each client receives the message. - * - * Returns an array of per-client latencies in the order they arrived. - */ -async function measureBroadcastLatency( - gateway: IntentsGateway, - clients: WebSocket[], - eventType = "load_test_ping", -): Promise<{ latencies: number[]; wallClockMs: number }> { - const TARGET_SEQ_MARKER = `__load_test_${Date.now()}`; - const received: number[] = []; - - const waitForAll = new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`broadcast not received by all clients within budget`)), - LATENCY_BUDGET_MS * 5, - ); - - const handlers: Map void> = new Map(); - - for (const ws of clients) { - const handler = (raw: Buffer) => { - const msg = JSON.parse(raw.toString()) as { type: string; marker?: string }; - if (msg.type === eventType && msg.marker === TARGET_SEQ_MARKER) { - received.push(Date.now()); - ws.off("message", handler); - handlers.delete(ws); - - if (handlers.size === 0) { - clearTimeout(timeout); - resolve(); - } - } - }; - handlers.set(ws, handler); - ws.on("message", handler); - } - }); - - // Kick off the broadcast and record the start time *after* the call returns - // (broadcast() is async — it resolves after subscriber chain lookups complete). - const broadcastStart = Date.now(); - await gateway.broadcast({ type: eventType, marker: TARGET_SEQ_MARKER }); - const broadcastEnd = Date.now(); - const wallClockMs = broadcastEnd - broadcastStart; - - await waitForAll; - - const latencies = received.map((t) => t - broadcastStart); - return { latencies, wallClockMs }; -} - -/** Close all clients and wait for them to finish. */ -async function closeClients(clients: WebSocket[]): Promise { - await Promise.all( - clients.map( - (ws) => - new Promise((resolve) => { - if (ws.readyState === WebSocket.CLOSED) { - resolve(); - return; - } - ws.on("close", () => resolve()); - ws.close(); - }), - ), - ); -} - -// ── suite ───────────────────────────────────────────────────────────────────── - -describe("WS broadcast fan-out load test (#84)", () => { - let app: INestApplication; - let wsUrl: string; - let gateway: IntentsGateway; - let previousMaxPerIp: string | undefined; - - beforeAll(async () => { - // Every client in this suite connects from 127.0.0.1, so the default - // per-IP admission cap (WS_MAX_CONNECTIONS_PER_IP, 20) would reject the - // 100- and 500-subscriber tiers at the handshake — the test would then - // measure the cap instead of fan-out. Raise it above the largest tier for - // this app only; production keeps the default. - previousMaxPerIp = process.env.WS_MAX_CONNECTIONS_PER_IP; - process.env.WS_MAX_CONNECTIONS_PER_IP = String( - Math.max(SUBSCRIBER_TIERS[SUBSCRIBER_TIERS.length - 1] + 100, 1000), - ); - - app = await createTestApp(); - // Listen on a random OS-assigned port to avoid collisions in CI - await app.listen(0); - const port = getWsPort(app); - wsUrl = `ws://127.0.0.1:${port}/ws`; - gateway = app.get(IntentsGateway); - }, 30_000); - - afterAll(async () => { - await app.close(); - if (previousMaxPerIp === undefined) { - delete process.env.WS_MAX_CONNECTIONS_PER_IP; - } else { - process.env.WS_MAX_CONNECTIONS_PER_IP = previousMaxPerIp; - } - }, 15_000); - - for (const subscriberCount of SUBSCRIBER_TIERS) { - // Use a describe block per tier so failures are clearly labelled - describe(`${subscriberCount} concurrent subscribers`, () => { - let clients: WebSocket[] = []; - - beforeAll(async () => { - clients = await openClients(wsUrl, subscriberCount); - }, 30_000); - - afterAll(async () => { - await closeClients(clients); - }, 15_000); - - it(`delivers broadcast to all ${subscriberCount} clients`, async () => { - const { latencies } = await measureBroadcastLatency(gateway, clients); - expect(latencies).toHaveLength(subscriberCount); - }, 30_000); - - it(`all clients receive within ${LATENCY_BUDGET_MS}ms latency budget`, async () => { - const { latencies } = await measureBroadcastLatency(gateway, clients, "latency_check"); - const slowClients = latencies.filter((l) => l > LATENCY_BUDGET_MS); - - // Log a summary regardless of pass/fail so CI logs are informative - const p50 = percentile(latencies, 50); - const p95 = percentile(latencies, 95); - const p99 = percentile(latencies, 99); - console.log( - `[load-test] subscribers=${subscriberCount} ` + - `p50=${p50}ms p95=${p95}ms p99=${p99}ms ` + - `slow=${slowClients.length}`, - ); - - expect(slowClients.length).toBe(0); - }, 30_000); - - it(`synchronous fan-out wall-clock time stays proportional`, async () => { - const heapBefore = process.memoryUsage().heapUsed; - - const { wallClockMs } = await measureBroadcastLatency(gateway, clients, "wall_clock_check"); - - const heapAfter = process.memoryUsage().heapUsed; - const heapGrowth = heapAfter - heapBefore; - - console.log( - `[load-test] subscribers=${subscriberCount} ` + - `wallClock=${wallClockMs}ms heapGrowth=${(heapGrowth / 1024).toFixed(1)}KB`, - ); - - // Wall-clock should be low because broadcast() iterates synchronously - // and ws.send() is non-blocking (it enqueues into libuv). - // Allow a loose upper bound rather than asserting an exact number. - expect(wallClockMs).toBeLessThan(1000); - expect(heapGrowth).toBeLessThan(HEAP_GROWTH_BUDGET_BYTES); - }, 30_000); - }); - } -}); - -// ── statistics helpers ──────────────────────────────────────────────────────── - -function percentile(sortedOrUnsorted: number[], p: number): number { - if (sortedOrUnsorted.length === 0) return 0; - const sorted = [...sortedOrUnsorted].sort((a, b) => a - b); - const idx = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, idx)]; -} diff --git a/test/oracle-min-dst.e2e-spec.ts b/test/oracle-min-dst.e2e-spec.ts new file mode 100644 index 0000000..8c814d0 --- /dev/null +++ b/test/oracle-min-dst.e2e-spec.ts @@ -0,0 +1,86 @@ +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { createTestApp } from "./utils/create-test-app"; +import { buildHighSlippageAckMessage } from "../src/common/stellar-signature"; + +const USER_KP = Keypair.fromSecret("SDIZIS4EXUZTSAHQM2BCYY2HQUZEB2FGQ5C3BJVSYKMU6PF5KIVEQ6V5"); + +function sign(kp: Keypair, msg: string): string { + return kp.sign(Buffer.from(msg, "utf8")).toString("base64"); +} + +const body = { + user: USER_KP.publicKey(), + srcChain: "ethereum" as const, + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + srcTokenSymbol: "USDC", + srcTokenDecimals: 6, + srcAmount: "1000000", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + dstTokenSymbol: "USDC", + dstTokenDecimals: 7, +}; + +describe("oracle minDstAmount validation (e2e, #434)", () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("accepts a 1% min and returns fairValue plus slippageBps", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...body, minDstAmount: "9900000" }) + .expect(201); + expect(res.body.fairValue).toBe("10000000"); + expect(res.body.slippageBps).toBe("100"); + }); + + it("rejects minDstAmount below MAX_USER_SLIPPAGE_BPS", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...body, minDstAmount: "9899000" }) + .expect(400); + expect(res.body.code).toBe("EXCESSIVE_SLIPPAGE"); + expect(res.body.fairValue).toBe("10000000"); + }); + + it("accepts acknowledged high slippage when the user signs", async () => { + const minDstAmount = "5000000"; + const signature = sign( + USER_KP, + buildHighSlippageAckMessage(USER_KP.publicKey(), body.srcAmount, minDstAmount), + ); + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ + ...body, + minDstAmount, + acknowledgeHighSlippage: true, + highSlippageSignature: signature, + }) + .expect(201); + expect(BigInt(res.body.slippageBps)).toBeGreaterThan(100n); + }); + + it("rejects minDstAmount above MAX_PREMIUM_BPS", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...body, minDstAmount: "10051000" }) + .expect(400); + expect(res.body.code).toBe("EXCESSIVE_PREMIUM"); + }); + + it("rejects acknowledgeHighSlippage without a signature", async () => { + await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...body, minDstAmount: "5000000", acknowledgeHighSlippage: true }) + .expect(400); + }); +}); diff --git a/test/params.e2e-spec.ts b/test/params.e2e-spec.ts index ee773f4..e69de29 100644 --- a/test/params.e2e-spec.ts +++ b/test/params.e2e-spec.ts @@ -1,179 +0,0 @@ -/** - * E2E tests for GET /api/v1/params (issue #500). - * - * Verifies the shape and semantics of the governance parameters endpoint - * against a real booted Nest app (no contract configured → code defaults). - */ - -// Default import: with `esModuleInterop` the namespace import is an object -// wrapper, not the callable `request` function, so `request(server)` throws -// "request is not a function". -import request from "supertest"; -import { INestApplication } from "@nestjs/common"; -import { createTestApp } from "./utils/create-test-app"; - -describe("GET /api/v1/params (e2e)", () => { - let app: INestApplication; - - beforeAll(async () => { - app = await createTestApp(); - }); - - afterAll(async () => { - await app.close(); - }); - - // -------------------------------------------------------------------------- - // Basic response shape - // -------------------------------------------------------------------------- - - it("returns 200 OK", async () => { - await request(app.getHttpServer()).get("/api/v1/params").expect(200); - }); - - it("returns JSON content-type", async () => { - await request(app.getHttpServer()) - .get("/api/v1/params") - .expect("Content-Type", /application\/json/); - }); - - it("response body has current, pending, and history keys", async () => { - const res = await request(app.getHttpServer()).get("/api/v1/params").expect(200); - expect(res.body).toHaveProperty("current"); - expect(res.body).toHaveProperty("pending"); - expect(res.body).toHaveProperty("history"); - }); - - // -------------------------------------------------------------------------- - // `current` object shape and defaults (no PARAMS_CONTRACT_ID configured) - // -------------------------------------------------------------------------- - - describe("current params (code defaults)", () => { - let current: Record; - - beforeAll(async () => { - const res = await request(app.getHttpServer()).get("/api/v1/params").expect(200); - current = res.body.current as Record; - }); - - it("has a numeric version >= 0", () => { - expect(typeof current["version"]).toBe("number"); - expect(current["version"] as number).toBeGreaterThanOrEqual(0); - }); - - it("has a numeric feeBps >= 0", () => { - expect(typeof current["feeBps"]).toBe("number"); - expect(current["feeBps"] as number).toBeGreaterThanOrEqual(0); - }); - - it("has feeBps = 30 (code default)", () => { - expect(current["feeBps"]).toBe(30); - }); - - it("has a chains object with stellar entry", () => { - expect(current["chains"]).toBeDefined(); - expect(typeof current["chains"]).toBe("object"); - const chains = current["chains"] as Record; - expect(chains["stellar"]).toBeDefined(); - }); - - it("stellar chain has deadlineSeconds = 900 (code default)", () => { - const chains = current["chains"] as Record; - expect(chains["stellar"]?.deadlineSeconds).toBe(900); - }); - - it("stellar chain has fillWindowSeconds = 120 (code default)", () => { - const chains = current["chains"] as Record; - expect(chains["stellar"]?.fillWindowSeconds).toBe(120); - }); - - it("has maxExposureRatio as a number between 0 and 1", () => { - expect(typeof current["maxExposureRatio"]).toBe("number"); - expect(current["maxExposureRatio"] as number).toBeGreaterThanOrEqual(0); - expect(current["maxExposureRatio"] as number).toBeLessThanOrEqual(1); - }); - - it("has slashAmount as a numeric string", () => { - expect(typeof current["slashAmount"]).toBe("string"); - expect(() => BigInt(current["slashAmount"] as string)).not.toThrow(); - }); - - it("has activeSinceLedger as a number", () => { - expect(typeof current["activeSinceLedger"]).toBe("number"); - }); - - it("has adoptedAt as an ISO-8601 date string", () => { - expect(typeof current["adoptedAt"]).toBe("string"); - expect(new Date(current["adoptedAt"] as string).toISOString()).toBe(current["adoptedAt"]); - }); - - it("includes all 7 supported chains in the chains object", () => { - const chains = current["chains"] as Record; - const expectedChains = [ - "stellar", - "ethereum", - "base", - "polygon", - "arbitrum", - "optimism", - "avalanche", - ]; - for (const chain of expectedChains) { - expect(chains[chain]).toBeDefined(); - } - }); - }); - - // -------------------------------------------------------------------------- - // `pending` — should be null when no contract is configured - // -------------------------------------------------------------------------- - - it("pending is null when PARAMS_CONTRACT_ID is not set", async () => { - const res = await request(app.getHttpServer()).get("/api/v1/params").expect(200); - expect(res.body.pending).toBeNull(); - }); - - // -------------------------------------------------------------------------- - // `history` — should be empty array on first boot - // -------------------------------------------------------------------------- - - it("history is an array", async () => { - const res = await request(app.getHttpServer()).get("/api/v1/params").expect(200); - expect(Array.isArray(res.body.history)).toBe(true); - }); - - it("history is empty on first boot (no contract, no changes)", async () => { - const res = await request(app.getHttpServer()).get("/api/v1/params").expect(200); - expect(res.body.history).toHaveLength(0); - }); - - // -------------------------------------------------------------------------- - // Security headers - // -------------------------------------------------------------------------- - - it("returns X-Content-Type-Options: nosniff (Helmet)", async () => { - const res = await request(app.getHttpServer()).get("/api/v1/params"); - expect(res.headers["x-content-type-options"]).toBe("nosniff"); - }); - - // -------------------------------------------------------------------------- - // Idempotency — two calls return consistent data - // -------------------------------------------------------------------------- - - it("returns identical current.version on successive calls", async () => { - const [r1, r2] = await Promise.all([ - request(app.getHttpServer()).get("/api/v1/params"), - request(app.getHttpServer()).get("/api/v1/params"), - ]); - expect(r1.body.current.version).toBe(r2.body.current.version); - expect(r1.body.current.feeBps).toBe(r2.body.current.feeBps); - }); - - // -------------------------------------------------------------------------- - // Swagger / OpenAPI registration - // -------------------------------------------------------------------------- - - it("GET /docs returns 200 (Swagger UI includes the params route)", async () => { - await request(app.getHttpServer()).get("/docs").expect(200); - }); -}); diff --git a/test/perf/k6/lib/helpers.js b/test/perf/k6/lib/helpers.js index 42ba298..2e98596 100644 --- a/test/perf/k6/lib/helpers.js +++ b/test/perf/k6/lib/helpers.js @@ -28,7 +28,7 @@ export const INTENT_BODY = { dstTokenContract: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA', dstTokenSymbol: 'USDC', dstTokenDecimals: 7, - minDstAmount: '990000', + minDstAmount: '9900000', }; /** Quote request body — no auth required. */ diff --git a/test/stats.e2e-spec.ts b/test/stats.e2e-spec.ts index 36ee5e1..454a3c9 100644 --- a/test/stats.e2e-spec.ts +++ b/test/stats.e2e-spec.ts @@ -49,7 +49,7 @@ describe("StatsController (e2e)", () => { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }) .expect(201); const intentId = createRes.body.intentId as string; @@ -63,13 +63,13 @@ describe("StatsController (e2e)", () => { const fillSig = sign(ALPHA_KP, buildFillMessage(intentId, ALPHA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${intentId}/fill`) - .send({ solver: ALPHA_KP.publicKey(), fillAmount: "995000", signature: fillSig }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "9950000", signature: fillSig }) .expect(201); const after = await request(app.getHttpServer()).get("/api/v1/stats").expect(200); expect(after.body.totalIntents).toBe(before.body.totalIntents + 1); - expect(BigInt(after.body.totalVolume) - BigInt(before.body.totalVolume)).toBe(995000n); + expect(BigInt(after.body.totalVolume) - BigInt(before.body.totalVolume)).toBe(9950000n); }); it("GET /api/v1/stats/ws returns the current WebSocket subscriber count", async () => { diff --git a/test/utils/create-test-app.ts b/test/utils/create-test-app.ts index 3e263bc..e99d849 100644 --- a/test/utils/create-test-app.ts +++ b/test/utils/create-test-app.ts @@ -66,6 +66,15 @@ export async function createTestApp(): Promise { // Mirror the production body-size limit so 413 tests behave correctly app.use(json({ limit: BODY_SIZE_LIMIT })); + app.use( + helmet({ + contentSecurityPolicy: false, + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + frameguard: { action: "deny" }, + noSniff: true, + referrerPolicy: { policy: "strict-origin-when-cross-origin" }, + }), + ); // Mirror the JSON depth-check middleware from main.ts (issue #476) app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/test/validation-negative-paths.e2e-spec.ts b/test/validation-negative-paths.e2e-spec.ts index c3766d5..3cc84b3 100644 --- a/test/validation-negative-paths.e2e-spec.ts +++ b/test/validation-negative-paths.e2e-spec.ts @@ -32,7 +32,7 @@ describe("Validation Negative Paths (e2e)", () => { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; // Distinct user per create attempt so the per-user create throttle diff --git a/test/ws-gateway.e2e-spec.ts b/test/ws-gateway.e2e-spec.ts index 393e679..515be0e 100644 --- a/test/ws-gateway.e2e-spec.ts +++ b/test/ws-gateway.e2e-spec.ts @@ -21,7 +21,7 @@ const validCreateBody = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; describe("IntentsGateway WebSocket (e2e)", () => {