From 8b39dfd0b52ff3db751a98f87e2cf725f7b7bff3 Mon Sep 17 00:00:00 2001 From: Goodnessukaigwe Date: Tue, 29 Sep 2026 09:44:53 +0100 Subject: [PATCH] feat(intents): validate minDstAmount against oracle fair value Reject creates whose minimum output sits too far below or above the oracle fair destination amount, unless high slippage is explicitly signed. Co-authored-by: Cursor --- .env.example | 14 + .env.mainnet.example | 14 + .env.staging.example | 14 + .env.testnet.example | 14 + .eslintrc.json | 31 ++- CHANGELOG.md | 6 + README.md | 6 +- docs/adr/0003-oracle-min-dst-validation.md | 38 +++ docs/rate-limits.md | 1 + docs/runbooks/on-call.md | 4 + jest.config.js | 3 + package-lock.json | 160 ++++++++++- package.json | 3 + src/app.module.ts | 24 +- src/common/stellar-signature.contract.spec.ts | 6 + src/common/stellar-signature.ts | 16 ++ src/config/configuration.ts | 39 +++ src/config/env.validation.ts | 20 +- src/governance/governance.module.ts | 4 +- src/health/health-indicator.registry.ts | 2 +- src/intents/dto/create-intent.dto.ts | 17 ++ .../intents-sweeper.manual-trigger.spec.ts | 2 +- src/intents/intents.controller.ts | 74 ++++- src/intents/intents.gateway.spec.ts | 23 +- src/intents/intents.gateway.ts | 8 +- src/intents/intents.module.ts | 4 +- src/intents/intents.service.shadow.spec.ts | 11 + src/intents/intents.service.spec.ts | 17 +- src/intents/intents.service.ts | 12 +- src/intents/solver-intent-matcher.ts | 19 +- src/intents/ws/connection-state.ts | 16 +- src/pricing/aggregator.service.spec.ts | 30 ++ src/pricing/aggregator.service.ts | 37 +++ src/pricing/min-dst-amount.validation.spec.ts | 262 ++++++++++++++++++ src/pricing/min-dst-amount.validation.ts | 241 ++++++++++++++++ src/pricing/pricing.module.ts | 11 + src/solvers/solvers.controller.ts | 99 +------ src/soroban/event-ingestion.service.ts | 2 +- src/soroban/signer.service.spec.ts | 41 +-- src/soroban/solver-registry.service.spec.ts | 25 ++ src/soroban/soroban.controller.spec.ts | 5 - src/soroban/soroban.module.ts | 20 +- src/soroban/soroban.service.ts | 8 +- src/soroban/stellar-tx.service.spec.ts | 50 +++- src/soroban/stellar-tx.service.ts | 25 +- src/soroban/tx-confirmation.service.ts | 2 +- src/tokens/in-memory-tokens.repository.ts | 1 - src/tokens/tokens.service.ts | 79 +----- src/treasury/treasury.service.spec.ts | 4 +- src/treasury/treasury.service.ts | 9 +- test/__mocks__/@stellar/stellar-sdk.ts | 88 ++---- test/__mocks__/nestjs-schedule.ts | 24 ++ test/audit-trail.e2e-spec.ts | 2 +- test/body-size.e2e-spec.ts | 4 +- test/cors.e2e-spec.ts | 16 ++ test/dos-limits.e2e-spec.ts | 11 +- test/intent-expiry.e2e-spec.ts | 2 +- test/intent-lifecycle.e2e-spec.ts | 12 +- test/intents.e2e-spec.ts | 10 +- test/jest-e2e.json | 3 +- test/load/concurrent-accept.test.ts | 10 +- .../load/concurrent-idempotent-create.test.ts | 7 +- test/load/ws-broadcast-fanout.test.ts | 7 + test/oracle-min-dst.e2e-spec.ts | 86 ++++++ test/params.e2e-spec.ts | 2 +- test/perf/k6/lib/helpers.js | 2 +- test/stats.e2e-spec.ts | 6 +- test/utils/create-test-app.ts | 11 +- test/validation-negative-paths.e2e-spec.ts | 2 +- test/ws-gateway.e2e-spec.ts | 2 +- 70 files changed, 1467 insertions(+), 413 deletions(-) create mode 100644 docs/adr/0003-oracle-min-dst-validation.md create mode 100644 src/pricing/aggregator.service.spec.ts create mode 100644 src/pricing/aggregator.service.ts create mode 100644 src/pricing/min-dst-amount.validation.spec.ts create mode 100644 src/pricing/min-dst-amount.validation.ts create mode 100644 src/pricing/pricing.module.ts create mode 100644 test/__mocks__/nestjs-schedule.ts create mode 100644 test/oracle-min-dst.e2e-spec.ts diff --git a/.env.example b/.env.example index 6ede42f..057917a 100644 --- a/.env.example +++ b/.env.example @@ -280,6 +280,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 8b62508..7f325a9 100644 --- a/.env.mainnet.example +++ b/.env.mainnet.example @@ -169,6 +169,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 91ba2b2..6b87a87 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -89,6 +89,20 @@ 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 (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.testnet.example b/.env.testnet.example index e12d80f..787125c 100644 --- a/.env.testnet.example +++ b/.env.testnet.example @@ -150,6 +150,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/.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 6f6a18d..c6a63d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ 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) + - `scripts/generate-client.ts` — generates a typed TypeScript API client from the live OpenAPI spec using `openapi-typescript` v7; output committed to `src/generated/` (Closes #134) diff --git a/README.md b/README.md index bc6693e..b3c1082 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,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 @@ -161,6 +161,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 276f33b..489719a 100644 --- a/docs/runbooks/on-call.md +++ b/docs/runbooks/on-call.md @@ -532,6 +532,10 @@ handlers never wait for Redis. | `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 | +| `MAX_USER_SLIPPAGE_BPS` | `100` | Reject intent create when `minDstAmount` is more than this many bps below oracle fair value unless the user signs `acknowledgeHighSlippage` | +| `MAX_PREMIUM_BPS` | `50` | Reject intent create when `minDstAmount` is more than this many bps above oracle fair value | +| `ORACLE_FAIL_OPEN_MAX_USD` | `100` | Missing/stale oracle: fail-open at or below this source notional (USD); fail-closed above it | +| `ORACLE_MAX_STALENESS_MS` | `60000` | Snapshots older than this are treated as unavailable | --- diff --git a/jest.config.js b/jest.config.js index 9ebcfd1..5c7b8f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,9 @@ module.exports = { // 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.) ───────────────────────────────── diff --git a/package-lock.json b/package-lock.json index 3aa78ac..0291b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/platform-ws": "^11.1.28", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^11.4.5", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.28", @@ -39,10 +40,12 @@ "helmet": "^7.1.0", "ioredis": "^6.0.0", "joi": "^18.2.3", + "parquetjs": "^0.11.2", "pg": "8.13.3", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "undici": "^7.30.0", "uuid": "^10.0.0", "winston": "^3.13.0", "ws": "^8.18.0", @@ -2818,6 +2821,22 @@ } } }, + "node_modules/@nestjs/schedule": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-12.0.2.tgz", + "integrity": "sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0 || ^12.0.0", + "@nestjs/core": "^11.0.0 || ^12.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "dev": true, @@ -5400,6 +5419,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz", + "integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==", + "license": "MIT" + }, "node_modules/@types/memcached": { "version": "2.2.10", "license": "MIT", @@ -6372,6 +6397,13 @@ "node": "*" } }, + "node_modules/bindings": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz", + "integrity": "sha512-u4cBQNepWxYA55FunZSM7wMi55yQaN0otnhhilNoWHq0MfOfJeQx0v0mRRpolGOExPjZcl6FtB0BB8Xkb88F0g==", + "license": "MIT", + "optional": true + }, "node_modules/bintrees": { "version": "1.0.2", "license": "MIT" @@ -6460,6 +6492,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.7", "dev": true, @@ -6511,6 +6552,15 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/buffer": { "version": "6.0.3", "funding": [ @@ -7121,6 +7171,23 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cron-parser": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.1.tgz", @@ -8911,6 +8978,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/int53": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/int53/-/int53-0.2.4.tgz", + "integrity": "sha512-a5jlKftS7HUOhkUyYD7j2sJ/ZnvWiNlZS1ldR+g1ifQ+/UuZXIE+YTc/lK1qGj/GwAU5F8Z0e1eVq2t1J5Ob2g==", + "license": "BSD-3-Clause" + }, "node_modules/ioredis": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz", @@ -10123,6 +10196,17 @@ "node": ">=12" } }, + "node_modules/lzo": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/lzo/-/lzo-0.4.11.tgz", + "integrity": "sha512-apQHNoW2Alg72FMqaC/7pn03I7umdgSVFt2KRkCXXils4Z9u3QBh1uOtl2O5WmZIDLd9g6Lu4lIdOLmiSTFVCQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "~1.2.1" + } + }, "node_modules/magic-string": { "version": "0.30.17", "dev": true, @@ -10542,7 +10626,6 @@ }, "node_modules/node-int64": { "version": "0.4.0", - "dev": true, "license": "MIT" }, "node_modules/node-releases": { @@ -10596,6 +10679,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-stream": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/object-stream/-/object-stream-0.0.1.tgz", + "integrity": "sha512-+NPJnRvX9RDMRY9mOWOo/NDppBjbZhXirNNSu2IBnuNboClC9h1ZGHXgHBLDbJMHsxeJDq922aVmG5xs24a/cA==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/on-finished": { "version": "2.4.1", "license": "MIT", @@ -10788,6 +10879,27 @@ "node": ">=6" } }, + "node_modules/parquetjs": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/parquetjs/-/parquetjs-0.11.2.tgz", + "integrity": "sha512-Y6FOc3Oi2AxY4TzJPz7fhICCR8tQNL3p+2xGQoUAMbmlJBR7+JJmMrwuyMjIpDiM7G8Wj/8oqOH4UDUmu4I5ZA==", + "license": "MIT", + "dependencies": { + "brotli": "^1.3.0", + "bson": "^1.0.4", + "int53": "^0.2.4", + "object-stream": "0.0.1", + "snappyjs": "^0.6.0", + "thrift": "^0.11.0", + "varint": "^5.0.0" + }, + "engines": { + "node": ">=7.6" + }, + "optionalDependencies": { + "lzo": "^0.4.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", "dev": true, @@ -11230,6 +11342,17 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.15.3", "license": "BSD-3-Clause", @@ -11803,6 +11926,12 @@ "node": ">=8" } }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/sodium-native": { "version": "4.3.3", "license": "MIT", @@ -12344,6 +12473,20 @@ "dev": true, "license": "MIT" }, + "node_modules/thrift": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/thrift/-/thrift-0.11.0.tgz", + "integrity": "sha512-UpsBhOC45a45TpeHOXE4wwYwL8uD2apbHTbtBvkwtUU4dNwCjC7DpQTjw2Q6eIdfNtw+dKthdwq94uLXTJPfFw==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0", + "q": "^1.5.0", + "ws": ">= 2.2.3" + }, + "engines": { + "node": ">= 4.1.0" + } + }, "node_modules/through": { "version": "2.3.8", "dev": true, @@ -12724,6 +12867,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "license": "MIT" @@ -12885,6 +13037,12 @@ "node": ">= 0.10" } }, + "node_modules/varint": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz", + "integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", diff --git a/package.json b/package.json index 2e44fb7..a4a8255 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/platform-ws": "^11.1.28", + "@nestjs/schedule": "^12.0.2", "@nestjs/swagger": "^11.4.5", "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.28", @@ -60,10 +61,12 @@ "helmet": "^7.1.0", "ioredis": "^6.0.0", "joi": "^18.2.3", + "parquetjs": "^0.11.2", "pg": "8.13.3", "prom-client": "^15.1.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "undici": "^7.30.0", "uuid": "^10.0.0", "winston": "^3.13.0", "ws": "^8.18.0", diff --git a/src/app.module.ts b/src/app.module.ts index b93ab1d..1647566 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,19 +11,16 @@ import { SolversModule } from "./solvers/solvers.module"; import { StatsModule } from "./stats/stats.module"; import { SorobanModule } from "./soroban/soroban.module"; import { RoutingModule } from "./routing/routing.module"; -import { MetricsModule } from "./metrics/metrics.module"; import { KillSwitchModule } from "./killswitch/killswitch.module"; import { PrismaModule } from "./prisma/prisma.module"; -import { MetricsModule } from "./metrics/metrics.module"; import { TreasuryModule } from "./treasury/treasury.module"; import { GovernanceModule } from "./governance/governance.module"; -import { MetricsModule } from "./metrics/metrics.module"; import { LeaderElectionModule } from "./common/leader-election"; import { AdminModule } from "./admin/admin.module"; import { JobsModule } from "./jobs/jobs.module"; import { FlagsModule } from "./flags/flags.module"; import { GuardianStateModule } from "./governance/guardian-state.service"; -import { DatasetsModule } from "./datasets/datasets.module"; +import { PricingModule } from "./pricing/pricing.module"; @Module({ imports: [ @@ -35,7 +32,6 @@ import { DatasetsModule } from "./datasets/datasets.module"; limit: 100, }, ]), - // Enable scheduled tasks (cron jobs) ScheduleModule.forRoot(), ConfigModule, PrismaModule, @@ -43,41 +39,25 @@ import { DatasetsModule } from "./datasets/datasets.module"; // for the whole app. Must be imported here or the global providers never // become visible to other modules (e.g. IntentsSweeperService). MetricsModule, - // Emergency pause control plane (issue #477). @Global() so KillSwitchGuard - // can gate write handlers in any module. KillSwitchModule, - // MetricsModule registers GET /metrics and the HTTP metrics interceptor. - // It is @Global(), so registering it here makes MetricsService injectable - // everywhere — which IntentsSweeperService, ShadowService and the SLO - // emitters all rely on. It must be listed exactly once, in the root - // module: dropping it from here leaves Nest unable to resolve - // MetricsService and the application fails to boot. - MetricsModule, - MetricsModule, - // Leader election must be initialised before any worker module so that - // LeaderElectionService is available when workers call registerWorker() - // in their onModuleInit hooks. LeaderElectionModule.forRoot(), - // Issues #494/#495/#507 — admin RBAC + audit, job queue, runtime flags, - // guardian-derived policy state. AdminModule, JobsModule, FlagsModule, GuardianStateModule, HealthModule, TokensModule, + PricingModule, IntentsModule, SolversModule, StatsModule, SorobanModule, RoutingModule, - MetricsModule, TreasuryModule, GovernanceModule, ], controllers: [], providers: [ - // Apply the IP-based throttle globally to every route { provide: APP_GUARD, useClass: ThrottlerGuard, 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 7724cc8..a8e25a5 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -100,6 +100,9 @@ export function buildDisputeReviewMessage(disputeId: string): string { */ export function buildDisputeDecisionMessage(disputeId: string, resolution: string, reason: string): string { return `dispute-decision:${disputeId}:${resolution}:${reason}`; +} + +/** * Build the canonical message that a solver must sign to update their mutable * profile fields (name / supportedChains / supportedTokens / avgFillTime). * @@ -110,3 +113,16 @@ export function buildDisputeDecisionMessage(disputeId: string, resolution: strin export function buildUpdateSolverMessage(address: string): string { return `update-solver:${address}`; } + +/** + * Canonical message a user must sign to acknowledge high slippage on create + * (issue #434). Bound to user + amounts so the signature cannot be reused + * for a different minDstAmount. + */ +export function buildHighSlippageAckMessage( + user: string, + srcAmount: string, + minDstAmount: string, +): string { + return `acknowledge-high-slippage:${user}:${srcAmount}:${minDstAmount}`; +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 710004e..c5d83de 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -266,6 +266,18 @@ export interface AppConfig { }; /** HS256 secret for solver JWTs (SEP-10 auth, #442); empty disables JWT auth. */ authJwtSecret: string; + /** + * Oracle-referenced minDstAmount gates (issue #434). + * + * Slippage/premium are integer basis points. `failOpenMaxUsd` is whole USD + * converted to 8-decimal scaled units at the validation boundary. + */ + oracle: { + maxUserSlippageBps: number; + maxPremiumBps: number; + failOpenMaxUsd: number; + maxStalenessMs: number; + }; /** Health probes (issue #492). */ health: { /** Roles this process serves; readiness requires every indicator critical to any of them. */ @@ -281,6 +293,17 @@ export interface AppConfig { /** Soroban RPC endpoints probed for quorum (majority must be healthy). */ rpcHealthUrls: string[]; }; + /** Public anonymised datasets (RFC 0001). */ + datasets: { + enabled: boolean; + anonymize: boolean; + salt: string; + saltRotationHours: number; + saltRetentionWindows: number; + publicBucket: string; + storageKind: "local" | "memory"; + localDir: string; + }; } export default (): AppConfig => ({ @@ -381,6 +404,12 @@ export default (): AppConfig => ({ slowConsumerPolicy: (process.env.WS_SLOW_CONSUMER_POLICY ?? "drop_oldest") as AppConfig["ws"]["slowConsumerPolicy"], }, authJwtSecret: process.env.AUTH_JWT_SECRET ?? "", + oracle: { + maxUserSlippageBps: parseInt(process.env.MAX_USER_SLIPPAGE_BPS ?? "100", 10), + maxPremiumBps: parseInt(process.env.MAX_PREMIUM_BPS ?? "50", 10), + failOpenMaxUsd: Number(process.env.ORACLE_FAIL_OPEN_MAX_USD ?? "100"), + maxStalenessMs: parseInt(process.env.ORACLE_MAX_STALENESS_MS ?? "60000", 10), + }, health: { roles: (process.env.SERVICE_ROLES ?? "api,ws,worker") .split(",") @@ -395,6 +424,16 @@ export default (): AppConfig => ({ .map((u) => u.trim()) .filter(Boolean), }, + datasets: { + enabled: (process.env.DATASETS_ENABLED ?? "false") === "true", + anonymize: (process.env.DATASETS_ANONYMIZE ?? "true") !== "false", + salt: process.env.DATASETS_SALT ?? "", + saltRotationHours: parseInt(process.env.DATASETS_SALT_ROTATION_HOURS ?? "24", 10), + saltRetentionWindows: parseInt(process.env.DATASETS_SALT_RETENTION_WINDOWS ?? "2", 10), + publicBucket: process.env.DATASETS_PUBLIC_BUCKET ?? "vortex-public-datasets", + storageKind: (process.env.DATASETS_STORAGE_KIND ?? "memory") as "local" | "memory", + localDir: process.env.DATASETS_LOCAL_DIR ?? "./data/datasets", + }, }); /** Parse `SHADOW_SAMPLE_RATE` into a probability, defaulting to full sampling. */ diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 5f9a5d2..3913218 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -26,6 +26,8 @@ export const envValidationSchema = Joi.object({ STELLAR_NETWORK: Joi.string().valid("testnet", "futurenet", "mainnet").default("testnet"), SOROBAN_RPC_URL: Joi.string().uri().default("https://soroban-testnet.stellar.org"), + HORIZON_URL: Joi.string().uri().default("https://horizon-testnet.stellar.org"), + TREASURY_ADDRESS: Joi.string().allow("").default(""), SETTLEMENT_CONTRACT_ID: Joi.string().allow("").default(""), SOLVER_REGISTRY_CONTRACT_ID: Joi.string().allow("").default(""), STELLAR_SIGNER_SECRET_KEY: Joi.string().allow("").default(""), @@ -348,7 +350,23 @@ export const envValidationSchema = Joi.object({ SOROBAN_RPC_ALLOWLIST: Joi.string().allow("").default(""), WEBHOOK_ALLOWLIST: Joi.string().allow("").default(""), ORACLE_ALLOWLIST: Joi.string().allow("").default(""), -}); + + // ── Oracle minDstAmount validation (issue #434) ─────────────────────────── + MAX_USER_SLIPPAGE_BPS: Joi.number().integer().min(0).max(10_000).default(100), + MAX_PREMIUM_BPS: Joi.number().integer().min(0).max(10_000).default(50), + ORACLE_FAIL_OPEN_MAX_USD: Joi.number().min(0).default(100), + ORACLE_MAX_STALENESS_MS: Joi.number().integer().min(0).default(60_000), + + // ── Public anonymised datasets (RFC 0001) ──────────────────────────────── + DATASETS_ENABLED: Joi.boolean().default(false), + DATASETS_ANONYMIZE: Joi.boolean().default(true), + DATASETS_SALT: Joi.string().allow("").default(""), + DATASETS_SALT_ROTATION_HOURS: Joi.number().integer().min(1).default(24), + DATASETS_SALT_RETENTION_WINDOWS: Joi.number().integer().min(0).default(2), + DATASETS_PUBLIC_BUCKET: Joi.string().default("vortex-public-datasets"), + DATASETS_STORAGE_KIND: Joi.string().valid("local", "memory").default("memory"), + DATASETS_LOCAL_DIR: Joi.string().default("./data/datasets"), + // ── WS gateway hardening (issue #455) ───────────────────────────────────── WS_MAX_PAYLOAD_BYTES: Joi.number().integer().min(1024).default(16384), WS_MAX_CONNECTIONS_PER_IP: Joi.number().integer().min(0).default(20), diff --git a/src/governance/governance.module.ts b/src/governance/governance.module.ts index ae876f5..106f0b7 100644 --- a/src/governance/governance.module.ts +++ b/src/governance/governance.module.ts @@ -1,4 +1,4 @@ -import { 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"; @@ -14,7 +14,7 @@ import { GuardianService } from "./guardian.service"; * inject it to snapshot parameters at intent-creation time. */ @Module({ - imports: [SorobanModule], + imports: [forwardRef(() => SorobanModule)], controllers: [ParamsController, GuardianController], providers: [ProtocolParamsService, GuardianService], exports: [ProtocolParamsService, GuardianService], diff --git a/src/health/health-indicator.registry.ts b/src/health/health-indicator.registry.ts index 1ae6343..f0a24cf 100644 --- a/src/health/health-indicator.registry.ts +++ b/src/health/health-indicator.registry.ts @@ -21,7 +21,7 @@ export interface HealthIndicator { check(): Promise; } -interface CachedResult extends IndicatorResult { +export interface CachedResult extends IndicatorResult { critical: boolean; checkedAt: string; durationMs: number; diff --git a/src/intents/dto/create-intent.dto.ts b/src/intents/dto/create-intent.dto.ts index 758f478..c80c879 100644 --- a/src/intents/dto/create-intent.dto.ts +++ b/src/intents/dto/create-intent.dto.ts @@ -1,4 +1,5 @@ import { + IsBoolean, IsIn, IsInt, IsOptional, @@ -105,4 +106,20 @@ export class CreateIntentDto { @IsOptional() @IsString() idempotencyKey?: string; + + @ApiPropertyOptional({ + description: + "When true, the user accepts minDstAmount below MAX_USER_SLIPPAGE_BPS of oracle fair value. Must be accompanied by highSlippageSignature.", + }) + @IsOptional() + @IsBoolean() + acknowledgeHighSlippage?: boolean; + + @ApiPropertyOptional({ + description: + "Base64 Ed25519 signature of acknowledge-high-slippage:::, required when acknowledgeHighSlippage is true.", + }) + @IsOptional() + @IsString() + highSlippageSignature?: string; } diff --git a/src/intents/intents-sweeper.manual-trigger.spec.ts b/src/intents/intents-sweeper.manual-trigger.spec.ts index 7ebbb73..bf811ca 100644 --- a/src/intents/intents-sweeper.manual-trigger.spec.ts +++ b/src/intents/intents-sweeper.manual-trigger.spec.ts @@ -52,8 +52,8 @@ describe("IntentsSweeperService — manual sweep trigger (#269)", () => { solverRegistry, metricsService, killSwitch, + noopLeaderElection(), ); - return new IntentsSweeperService(intentsService, gateway, solversService, solverRegistry, metricsService, noopLeaderElection()); } afterEach(() => jest.restoreAllMocks()); diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 1404059..5048aa1 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -46,7 +46,13 @@ import { buildAcceptMessage, buildCancelMessage, buildFillMessage, + buildHighSlippageAckMessage, } from "../common/stellar-signature"; +import { AggregatorService } from "../pricing/aggregator.service"; +import { + USD_PRICE_SCALE, + validateMinDstAmount, +} from "../pricing/min-dst-amount.validation"; import { applyVarianceScale, calculateProtocolFee, @@ -76,7 +82,8 @@ export class IntentsController { private readonly tokensService: TokensService, private readonly routingService: RoutingService, private readonly killSwitch: KillSwitchService, - config: ConfigService, + private readonly aggregator: AggregatorService, + private readonly config: ConfigService, ) { this.canary = new Set(config.get("canaryAddresses", { infer: true }) ?? []); } @@ -242,11 +249,23 @@ export class IntentsController { @Post() @UseGuards(UserThrottlerGuard, KillSwitchGuard) @KillSwitchGate({ operation: "create" }) + @ApiOperation({ + summary: "Create a swap intent", + description: + "Validates `minDstAmount` against the oracle aggregator's fair destination value. " + + "Slippage above `MAX_USER_SLIPPAGE_BPS` requires `acknowledgeHighSlippage` plus a signed " + + "`acknowledge-high-slippage:::` message. Premium above " + + "`MAX_PREMIUM_BPS` is always rejected. The 201 body includes `fairValue` (dst base units, " + + "or null on oracle fail-open) and `slippageBps`.", + }) @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" }) + @ApiBadRequestResponse({ + description: + "Invalid request body, excessive slippage/premium vs oracle fair value, or oracle unavailable above the fail-open USD threshold", + }) @ApiConflictResponse({ description: `Open-intent cap reached — a single user may not hold more than ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents simultaneously`, }) @@ -272,6 +291,51 @@ export class IntentsController { ); const dstToken = await this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract); + if (dto.acknowledgeHighSlippage === true) { + if (!dto.highSlippageSignature) { + throw new BadRequestException( + "acknowledgeHighSlippage requires highSlippageSignature over acknowledge-high-slippage:::", + ); + } + verifyStellarSignature( + dto.user, + buildHighSlippageAckMessage(dto.user, dto.srcAmount, dto.minDstAmount), + dto.highSlippageSignature, + ); + } + + const snapshot = await this.aggregator.getPriceSnapshot({ + srcChain: dto.srcChain as SupportedChain, + srcTokenAddress: dto.srcTokenAddress, + dstTokenContract: dto.dstTokenContract, + }); + const oracle = this.config.get("oracle", { infer: true }); + const minDstCheck = validateMinDstAmount( + { + srcAmount: dto.srcAmount, + srcDecimals: srcToken.decimals, + dstDecimals: dstToken.decimals, + minDstAmount: dto.minDstAmount, + acknowledgeHighSlippage: dto.acknowledgeHighSlippage === true, + nowMs: Date.now(), + snapshot, + }, + { + maxUserSlippageBps: BigInt(oracle.maxUserSlippageBps), + maxPremiumBps: BigInt(oracle.maxPremiumBps), + failOpenMaxUsd: BigInt(Math.trunc(oracle.failOpenMaxUsd)) * USD_PRICE_SCALE, + maxStalenessMs: oracle.maxStalenessMs, + }, + ); + if (!minDstCheck.ok) { + throw new BadRequestException({ + error: minDstCheck.error, + code: minDstCheck.code, + fairValue: minDstCheck.fairValue?.toString() ?? null, + slippageBps: minDstCheck.slippageBps?.toString() ?? null, + }); + } + const intent = await this.intentsService.create( { user: dto.user, @@ -297,7 +361,11 @@ export class IntentsController { dto.idempotencyKey, ); this.intentsGateway.broadcast({ type: "intent_created", intent }); - return intent; + return { + ...intent, + fairValue: minDstCheck.fairValue?.toString() ?? null, + slippageBps: minDstCheck.slippageBps.toString(), + }; } /** diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index eb06738..f8920e8 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -9,6 +9,7 @@ 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"; jest.mock("../common/logger", () => ({ logger: { @@ -134,7 +135,7 @@ describe("IntentsGateway heartbeat", () => { jest.clearAllMocks(); intentsService = makeIntentsService(); solversService = makeSolversService(); - gateway = new IntentsGateway(intentsService, solversService); + gateway = new IntentsGateway(intentsService, solversService, new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -231,10 +232,16 @@ describe("IntentsGateway heartbeat", () => { const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature })); - expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_ok" })); + expect(client.send).toHaveBeenCalledWith( + JSON.stringify({ type: "auth_ok", method: "signature" }), + expect.any(Function), + ); await client._listeners.message(JSON.stringify({ type: "auth", solver: keypair.publicKey(), timestamp, signature: "bad" })); - expect(client.send).toHaveBeenLastCalledWith(JSON.stringify({ type: "auth_error", reason: "invalid solver signature" })); + expect(client.send).toHaveBeenLastCalledWith( + JSON.stringify({ type: "auth_error", reason: "invalid solver signature" }), + expect.any(Function), + ); }); }); @@ -250,7 +257,7 @@ describe("IntentsGateway logging", () => { jest.clearAllMocks(); intentsService = makeIntentsService(); solversService = makeSolversService(); - gateway = new IntentsGateway(intentsService, solversService); + gateway = new IntentsGateway(intentsService, solversService, new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -259,7 +266,7 @@ describe("IntentsGateway logging", () => { }); it("logs heartbeat started on construction", () => { - expect(logger.info).toHaveBeenCalledWith("ws heartbeat started"); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("ws heartbeat started")); }); it("logs connection with subscriber count", () => { @@ -310,7 +317,7 @@ describe("IntentsGateway — chain subscription filtering (#257)", () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService()); + gateway = new IntentsGateway(intentsService, makeSolversService(), new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -467,7 +474,7 @@ describe("IntentsGateway — event replay (#258)", () => { jest.useFakeTimers(); jest.clearAllMocks(); intentsService = makeIntentsService(); - gateway = new IntentsGateway(intentsService, makeSolversService()); + gateway = new IntentsGateway(intentsService, makeSolversService(), new IntentCapabilityIndex(intentsService)); }); afterEach(() => { @@ -504,7 +511,7 @@ describe("IntentsGateway — event replay (#258)", () => { 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()); + const tinyGateway = new IntentsGateway(intentsService, makeSolversService(), new IntentCapabilityIndex(intentsService)); // @ts-expect-error – accessing private field for test setup tinyGateway.ringBuffer["capacity"] = 2; diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index 0f7a29a..b6faeec 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -294,9 +294,7 @@ export class IntentsGateway this.alive.set(client, true); this.metricsService?.incWsConnection(); - client.on("message", (raw) => { - void this.handleMessage(client, raw); - }); + client.on("message", (raw) => this.handleMessage(client, raw)); client.on("pong", () => { this.alive.set(client, true); @@ -724,7 +722,9 @@ export class IntentsGateway // Non-fatal — solver can fall back to GET /solvers/:address/eligible-intents. } - logger.info(`ws solver auth ok: address=${solver} chains=${solverRecord.supportedChains.join(",")} tokens=${solverRecord.supportedTokens.join(",")}`); + logger.info( + `ws solver auth ok: address=${solver} chains=${(solverRecord.supportedChains ?? []).join(",")} tokens=${(solverRecord.supportedTokens ?? []).join(",")}`, + ); } /** diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 378b82f..0673cac 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -17,6 +17,7 @@ import { SorobanModule } from "../soroban/soroban.module"; import { AppConfig } from "../config/configuration"; import { PrismaService } from "../prisma/prisma.service"; import { GovernanceModule } from "../governance/governance.module"; +import { PricingModule } from "../pricing/pricing.module"; @Module({ // Both SolversModule and SorobanModule import IntentsModule back, so both @@ -29,9 +30,10 @@ import { GovernanceModule } from "../governance/governance.module"; forwardRef(() => SolversModule), RoutingModule, TokensModule, + PricingModule, forwardRef(() => SorobanModule), + GovernanceModule, ], - imports: [forwardRef(() => SolversModule), RoutingModule, TokensModule, SorobanModule, GovernanceModule], controllers: [IntentsController], providers: [ // Select the persistence adapter based on INTENTS_PERSISTENCE env var. diff --git a/src/intents/intents.service.shadow.spec.ts b/src/intents/intents.service.shadow.spec.ts index 2d289f0..a0bb472 100644 --- a/src/intents/intents.service.shadow.spec.ts +++ b/src/intents/intents.service.shadow.spec.ts @@ -7,6 +7,7 @@ import { ShadowService, type ShadowObservationRequest } from "../soroban/shadow. import { StellarTxService } from "../soroban/stellar-tx.service"; import { IntentsService } from "./intents.service"; import { InMemoryIntentsRepository } from "./intents.repository"; +import { ProtocolParamsService } from "../governance/params.service"; /** * Wiring tests for the shadow-mode divergence monitor at its real call sites @@ -69,11 +70,21 @@ interface Harness { function makeService(options: { accepts?: boolean } = {}): Harness { const shadow = fakeShadowService(options.accepts ?? true); const metrics = fakeMetricsService(); + const protocolParams = { + snapshotForChain: jest.fn().mockReturnValue({ + version: 0, + feeBps: 30, + deadlineSeconds: 1800, + fillWindowSeconds: 600, + capturedAt: new Date().toISOString(), + }), + } as unknown as ProtocolParamsService; const service = new IntentsService( new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), fakePrismaService(), + protocolParams, shadow as unknown as ShadowService, metrics, ); diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index a9867fe..a6b8620 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -372,10 +372,10 @@ describe("IntentsService", () => { const stellarTxService = fakeStellarTxService(); const svc = makeService({ onchainIntentsEnabled: false }, stellarTxService); - const intent = await service.create(validCreateData()); + const intent = await svc.create(validCreateData()); expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); - expect(await service.get(intent.intentId)).toEqual(intent); + expect(await svc.get(intent.intentId)).toEqual(intent); }); it("invokes the settlement contract and preserves the Intent shape when the flag is on", async () => { @@ -387,7 +387,7 @@ describe("IntentsService", () => { ); const data = validCreateData(); - const intent = await service.create(data); + const intent = await svc.create(data); expect(stellarTxService.invokeContract).toHaveBeenCalledTimes(1); const call = stellarTxService.invokeContract.mock.calls[0][0]; @@ -407,17 +407,16 @@ describe("IntentsService", () => { state: "", createdAt: 0, deadline: 0, + paramsVersion: 0, }).sort(), ); - expect(await service.get(intent.intentId)).toBeDefined(); + expect(await svc.get(intent.intentId)).toBeDefined(); }); it("rejects with a clear error and does not create the intent when SETTLEMENT_CONTRACT_ID is unset", async () => { const stellarTxService = fakeStellarTxService(); const service = makeService({ onchainIntentsEnabled: true }, stellarTxService); const before = (await service.getAll()).length; - const svc = makeService({ onchainIntentsEnabled: true }, stellarTxService); - const before = (await svc.getAll()).length; await expect(service.create(validCreateData())).rejects.toMatchObject({ message: expect.stringContaining("SETTLEMENT_CONTRACT_ID"), @@ -433,10 +432,10 @@ describe("IntentsService", () => { { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, stellarTxService, ); - const before = (await service.getAll()).length; + const before = (await svc.getAll()).length; - await expect(service.create(validCreateData())).rejects.toThrow(/settlement contract/i); - expect(await service.getAll()).toHaveLength(before); + await expect(svc.create(validCreateData())).rejects.toThrow(/settlement contract/i); + expect(await svc.getAll()).toHaveLength(before); }); }); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index c8222ed..3c77398 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, ) {} @@ -533,9 +533,6 @@ export class IntentsService { nativeToScVal(intent.deadline, { type: "u64" }), ]), ); - const snapshot = this.protocolParamsService.snapshotForChain(intent.srcChain); - const fillWindow = snapshot.fillWindowSeconds; - return this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec); } /** @@ -654,6 +651,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, @@ -661,9 +659,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 a0964c5..1cd5601 100644 --- a/src/intents/solver-intent-matcher.ts +++ b/src/intents/solver-intent-matcher.ts @@ -40,20 +40,21 @@ export interface SolverMatchPredicate { * it is cheap to evaluate (no object allocations per intent check). */ export function buildMatchPredicate(solver: SolverRecord): SolverMatchPredicate { - const chainSet = new Set(solver.supportedChains); - const tokenSet = new Set( - solver.supportedTokens.map((t) => t.toLowerCase()), - ); - const hasBond = BigInt(solver.bondAmount) > 0n; + const chains = solver.supportedChains ?? []; + const tokens = solver.supportedTokens ?? []; + const chainSet = new Set(chains); + const tokenSet = new Set(tokens.map((t) => t.toLowerCase())); + const hasBond = BigInt(solver.bondAmount ?? "0") > 0n; return { solverAddress: solver.address, - supportedChains: [...solver.supportedChains], - supportedTokens: [...solver.supportedTokens], - bondAmount: solver.bondAmount, + supportedChains: [...chains], + supportedTokens: [...tokens], + bondAmount: solver.bondAmount ?? "0", matches(intent: Intent): boolean { if (!hasBond) return false; - if (!chainSet.has(intent.srcChain)) return false; + if (chains.length > 0 && !chainSet.has(intent.srcChain)) return false; + if (tokens.length === 0) return true; const symbol = typeof intent.srcToken === "object" && intent.srcToken !== null ? // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/intents/ws/connection-state.ts b/src/intents/ws/connection-state.ts index 0973ccd..17a6a75 100644 --- a/src/intents/ws/connection-state.ts +++ b/src/intents/ws/connection-state.ts @@ -1,4 +1,4 @@ -import type { WebSocket } from "ws"; +import { WebSocket } from "ws"; /** Classic token bucket: `ratePerSec` sustained, up to `burst` at once. */ export class TokenBucket { @@ -83,8 +83,8 @@ export class ConnectionState { } send(payload: string): OutboundResult { - if (this.closed || this.socket.readyState !== this.socket.OPEN) return "sent"; - if (this.queue.length === 0 && this.socket.bufferedAmount < this.limits.bufferBytes) { + if (this.closed || this.socket.readyState !== WebSocket.OPEN) return "sent"; + if (this.queue.length === 0 && this.bufferedAmount() < this.limits.bufferBytes) { this.write(payload); return "sent"; } @@ -104,6 +104,12 @@ export class ConnectionState { this.queue.length = 0; } + /** `bufferedAmount` is 0 until the socket reports bytes waiting in the kernel buffer. */ + private bufferedAmount(): number { + const buffered = this.socket.bufferedAmount; + return typeof buffered === "number" ? buffered : 0; + } + private write(payload: string) { this.socket.send(payload, () => this.flush()); } @@ -112,8 +118,8 @@ export class ConnectionState { while ( !this.closed && this.queue.length > 0 && - this.socket.readyState === this.socket.OPEN && - this.socket.bufferedAmount < this.limits.bufferBytes + this.socket.readyState === WebSocket.OPEN && + this.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/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 1898b82..94394ec 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -6,6 +6,7 @@ import { Get, NotFoundException, Param, + Patch, Post, Query, } from "@nestjs/common"; @@ -18,18 +19,11 @@ import { ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { IntentsService } from "../intents/intents.service"; -import { buildDisputeMessage, buildRegisterMessage, buildUpdateSolverMessage, verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; -import { SolversService, LeaderboardWindow, solverSupports } from "./solvers.service"; -import { ListIntentsDto } from "../intents/dto/list-intents.dto"; -import { ApiNotFoundResponse, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; import { ConfigService } from "@nestjs/config"; import { AppConfig } from "../config/configuration"; import { isCanaryIntent } from "../common/canary"; import { IntentsService } from "../intents/intents.service"; import { IntentCapabilityIndex } from "../intents/solver-intent-matcher"; -import { buildDisputeMessage, verifyStellarSignature, buildSolverStatusMessage, buildRegisterMessage } from "../common/stellar-signature"; -import { SUPPORTED_CHAINS, SupportedChain } from "../intents/intents.types"; import { buildDisputeMessage, buildRegisterMessage, @@ -38,7 +32,6 @@ import { verifyStellarSignature, } from "../common/stellar-signature"; import { SolversService, LeaderboardWindow } from "./solvers.service"; -import { SolverRecord } from "./solvers.types"; import { RegisterSolverDto } from "./dto/register-solver.dto"; import { UpdateSolverDto } from "./dto/update-solver.dto"; import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; @@ -50,45 +43,6 @@ const WINDOW_SECONDS: Record, number> = { "30d": 30 * 24 * 60 * 60, }; -/** - * Whether `solver` is able to work `chain`/`tokenSymbol` at all. - * - * A solver with no declared chains or tokens is treated as unrestricted — that - * matches registration defaults, where the fields are optional declarations - * of focus rather than a hard allow-list, and it keeps existing solvers - * eligible for intents created before the fields existed. - * - * Matching is case-insensitive on the token symbol because registries and - * user-supplied intent payloads disagree on casing (e.g. "USDC" vs "usdc"). - */ -function solverSupports( - solver: SolverRecord, - chain: string, - tokenSymbol: string, -): boolean { - if (solver.supportedChains.length > 0) { - const supportsChain = solver.supportedChains.some( - (c: SupportedChain) => c.toLowerCase() === String(chain).toLowerCase(), - ); - if (!supportsChain) return false; - } - - if (solver.supportedTokens.length > 0) { - const needle = String(tokenSymbol).toLowerCase(); - const supportsToken = solver.supportedTokens.some( - (t: string) => String(t).toLowerCase() === needle, - ); - if (!supportsToken) return false; - } - - return true; -} - -/** Guard against chain values that are not part of the supported set. */ -function isSupportedChain(value: string): value is SupportedChain { - return (SUPPORTED_CHAINS as readonly string[]).includes(value); -} - @ApiTags("solvers") @Controller("api/v1/solvers") export class SolversController { @@ -234,12 +188,6 @@ export class SolversController { // Use the capability index for O(supported-chains × supported-tokens) // lookup instead of scanning all open intents (issue #436). const eligible = this.intentIndex.getEligibleFor(solver); - const open = await this.intentsService.getByState("open"); - const eligible = open.filter( - (intent) => - isSupportedChain(intent.srcChain) && - solverSupports(solver, intent.srcChain, intent.srcToken.symbol), - ); const limit = Math.min(dto.limit ?? 20, 100); const offset = dto.offset ?? 0; @@ -270,6 +218,14 @@ export class SolversController { * stripped by the DTO whitelist. */ @Patch(":address") + @ApiOperation({ + summary: "Update a solver's mutable profile fields", + description: + "Partial update of name, supportedChains, supportedTokens and avgFillTime. " + + "Requires an Ed25519 signature over the message `update-solver:
` " + + "produced by the solver's own key. Array fields are replaced wholesale. " + + "Immutable fields are silently ignored.", + }) @ApiOkResponse({ description: "Updated solver record" }) @ApiBadRequestResponse({ description: "Invalid update body" }) @ApiUnauthorizedResponse({ description: "Missing or invalid signature" }) @@ -415,43 +371,6 @@ export class SolversController { return solver; } - /** - * PATCH /api/v1/solvers/:address — issue #273. - * - * Partial update of the solver's *mutable* profile fields. Requires an - * Ed25519 signature over `update-solver:
` from the solver's own - * key, so a third party cannot rewrite another solver's listing. - * - * Immutable fields (bond, fill counters, volume, registeredAt, isActive) are - * not present on `UpdateSolverDto`, so the global - * `ValidationPipe({ whitelist: true })` strips them from the body before the - * handler runs — they are silently ignored rather than rejected. - */ - @Patch(":address") - @ApiOperation({ - summary: "Update a solver's mutable profile fields", - description: - "Partial update of name, supportedChains, supportedTokens and avgFillTime. " + - "Requires an Ed25519 signature over the message `update-solver:
` " + - "produced by the solver's own key. Array fields are replaced wholesale. " + - "Immutable fields are silently ignored.", - }) - @ApiNotFoundResponse({ description: "Solver not found" }) - async update(@Param("address") address: string, @Body() dto: UpdateSolverDto) { - verifyStellarSignature(address, buildUpdateSolverMessage(address), dto.signature); - - const updated = await this.solversService.update(address, { - name: dto.name, - avgFillTime: dto.avgFillTime, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - }); - - if (!updated) throw new NotFoundException("Solver not found"); - return updated; - } - - private normalizeWindow(window?: string): LeaderboardWindow { const normalized = (window ?? "all").toLowerCase(); if (normalized === "all" || normalized === "24h" || normalized === "7d" || normalized === "30d") { diff --git a/src/soroban/event-ingestion.service.ts b/src/soroban/event-ingestion.service.ts index 0a33ba8..d806e58 100644 --- a/src/soroban/event-ingestion.service.ts +++ b/src/soroban/event-ingestion.service.ts @@ -61,6 +61,7 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { private readonly sorobanService: SorobanService, private readonly configService: ConfigService, private readonly solversService: SolversService, + private readonly leaderElection: LeaderElectionService, /** * SLO emitters for the on-chain dashboard. `@Optional()` so the unit tests * that construct this service directly do not need a metrics registry; @@ -74,7 +75,6 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { * where the intent service is reached through a `forwardRef`. */ @Optional() private readonly intentsService?: IntentsService, - private readonly leaderElection: LeaderElectionService, ) {} onModuleInit() { diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 6e883f5..93c3f59 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -5,6 +5,7 @@ import { AppConfig } from "../config/configuration"; import { SignerService } from "./signer.service"; import { SorobanService } from "./soroban.service"; import { findSensitiveKeyMaterial } from "./redaction"; +import { LocalKeypairSigner } from "./signers/local-keypair.signer"; function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { const values: Record = { @@ -20,20 +21,28 @@ function fakeSorobanService(startingSequence = "100") { } as unknown as jest.Mocked; } +function makeSignerService( + secret: string, + network: AppConfig["stellar"]["network"] = "testnet", + soroban: jest.Mocked = fakeSorobanService(), +): SignerService { + return new SignerService(new LocalKeypairSigner(configWith(secret, network)), soroban); +} + describe("SignerService", () => { it("reports unconfigured when no secret is set", () => { - const service = new SignerService(configWith(""), fakeSorobanService()); + const service = makeSignerService(""); expect(service.isConfigured()).toBe(false); }); it("throws a clear, secret-free error when signing without a configured key", () => { - const service = new SignerService(configWith(""), fakeSorobanService()); - expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNER_SECRET_KEY/); + const service = makeSignerService(""); + expect(() => service.getPublicKey()).toThrow(/SOROBAN_SIGNING_KEY/); }); it("derives the public key from the configured secret", () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); + const service = makeSignerService(keypair.secret()); expect(service.isConfigured()).toBe(true); expect(service.getPublicKey()).toBe(keypair.publicKey()); @@ -41,14 +50,14 @@ describe("SignerService", () => { it("maps network config to the right passphrase", () => { const soroban = fakeSorobanService(); - expect(new SignerService(configWith("", "testnet"), soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); - expect(new SignerService(configWith("", "futurenet"), soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); - expect(new SignerService(configWith("", "mainnet"), soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC); + expect(makeSignerService("", "testnet", soroban).getNetworkPassphrase()).toBe(Networks.TESTNET); + expect(makeSignerService("", "futurenet", soroban).getNetworkPassphrase()).toBe(Networks.FUTURENET); + expect(makeSignerService("", "mainnet", soroban).getNetworkPassphrase()).toBe(Networks.PUBLIC); }); - it("signs a transaction with the configured key", () => { + it("signs a transaction with the configured key", async () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService()); + const service = makeSignerService(keypair.secret()); const account = new Account(keypair.publicKey(), "1"); const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET }) @@ -57,13 +66,13 @@ describe("SignerService", () => { .build(); expect(tx.signatures).toHaveLength(0); - const signed = service.sign(tx); + 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(configWith(keypair.secret()), fakeSorobanService()); + const service = makeSignerService(keypair.secret()); const secret = keypair.secret(); expect(String(service)).not.toContain(secret); @@ -88,7 +97,7 @@ describe("SignerService", () => { it("fetches the starting sequence once and increments it locally", async () => { const keypair = Keypair.random(); const soroban = fakeSorobanService("100"); - const service = new SignerService(configWith(keypair.secret()), soroban); + const service = makeSignerService(keypair.secret(), "testnet", soroban); const first = await service.withNextSequence(async (sequence) => sequence); const second = await service.withNextSequence(async (sequence) => sequence); @@ -101,7 +110,7 @@ describe("SignerService", () => { 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(configWith(keypair.secret()), soroban); + const service = makeSignerService(keypair.secret(), "testnet", soroban); const results = await Promise.all( Array.from({ length: 20 }, () => service.withNextSequence(async (sequence) => sequence)), @@ -114,7 +123,7 @@ describe("SignerService", () => { it("runs callers strictly one at a time, in call order", async () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService("0")); + const service = makeSignerService(keypair.secret(), "testnet", fakeSorobanService("0")); const order: number[] = []; const slow = service.withNextSequence(async () => { @@ -132,7 +141,7 @@ describe("SignerService", () => { 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(configWith(keypair.secret()), soroban); + const service = makeSignerService(keypair.secret(), "testnet", soroban); await expect( service.withNextSequence(async () => { @@ -147,7 +156,7 @@ describe("SignerService", () => { it("does not let a failed caller block callers queued behind it", async () => { const keypair = Keypair.random(); - const service = new SignerService(configWith(keypair.secret()), fakeSorobanService("0")); + const service = makeSignerService(keypair.secret(), "testnet", fakeSorobanService("0")); const failing = service.withNextSequence(async () => { throw new Error("boom"); diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index 043fafd..1f4611c 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -9,6 +9,7 @@ function makeConfigService( const stellar: AppConfig["stellar"] = { network: "testnet", sorobanRpcUrl: "https://soroban-testnet.stellar.org", + horizonUrl: "https://horizon-testnet.stellar.org", settlementContractId: "", solverRegistryContractId: "", signerSecretKey: "", @@ -70,6 +71,30 @@ function makeConfigService( slowConsumerPolicy: "drop_oldest", }, authJwtSecret: "", + treasury: { address: "" }, + shadow: { + enabled: false, + sampleRate: 1, + queueMax: 256, + concurrency: 1, + sourceAccount: "", + }, + oracle: { + maxUserSlippageBps: 100, + maxPremiumBps: 50, + failOpenMaxUsd: 100, + maxStalenessMs: 60_000, + }, + datasets: { + enabled: false, + anonymize: true, + salt: "", + saltRotationHours: 24, + saltRetentionWindows: 2, + publicBucket: "vortex-public-datasets", + storageKind: "memory", + localDir: "./data/datasets", + }, health: { roles: ["api", "ws", "worker"], checkIntervalMs: 5000, diff --git a/src/soroban/soroban.controller.spec.ts b/src/soroban/soroban.controller.spec.ts index af2f381..157b021 100644 --- a/src/soroban/soroban.controller.spec.ts +++ b/src/soroban/soroban.controller.spec.ts @@ -112,9 +112,6 @@ describe("SorobanController", () => { describe("getAccount", () => { const PUBLIC_KEY = "GA6NGB7335VYC4CEHUN6KZD2NNESU36BKCL5LZCTTY6ICW5ZVII67FF4"; - // Real strkeys (valid CRC16). A well-formed-looking "G…" string with a bad - // checksum is rejected by the controller, so fixtures must be genuine. - const PUBLIC_KEY = "GAMS2CGT4CPVYB5LSZV3FAOYFJK67574RS5HASJNTNS7WEUO3CN6ADW4"; const OTHER_KEY = "GCGMQIBI2B64NO4JI5IRXUOKFQYFUXBRJUUQBOHJQZA34JOKFU3W2WVK"; it("passes the publicKey path param through to sorobanService.getAccount", async () => { @@ -129,8 +126,6 @@ describe("SorobanController", () => { }); it("passes a different publicKey correctly", async () => { - const anotherKey = "GBCI24BNYGGIRDE4PCUD6PJAQINUVQPIUJCBJT4HTZZONEXNVVDIYVAC"; - mockSorobanService.getAccount.mockResolvedValueOnce({ id: anotherKey }); mockSorobanService.getAccount.mockResolvedValueOnce({ id: OTHER_KEY }); await controller.getAccount(OTHER_KEY); diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index f8b97d0..20c77e9 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -1,5 +1,4 @@ import { forwardRef, Module } from "@nestjs/common"; -import { Module, forwardRef } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { EventIngestionService } from "./event-ingestion.service"; import { ShadowController } from "./shadow.controller"; @@ -14,32 +13,17 @@ import { SolverRegistryEventsService } from "./events/solver-registry-events.ser import { SIGNER_TOKEN, signerFactory } from "./signers/signer.factory"; import { SolversModule } from "../solvers/solvers.module"; import { MetricsService } from "../metrics/metrics.service"; -import { AppConfig } from "../config/configuration"; -import { IntentsModule } from "../intents/intents.module"; -import { SolversModule } from "../solvers/solvers.module"; import { IntentsModule } from "../intents/intents.module"; // MetricsModule is @Global() and registered in AppModule, so the MetricsService // that ShadowService emits its counters through needs no import here. @Module({ - // SorobanModule <-> SolversModule <-> IntentsModule (which imports this - // module) form a CommonJS cycle. SolversModule must be resolved lazily so - // that evaluating this file never triggers IntentsModule's module decorator - // while SorobanModule is still partially initialised. - // eslint-disable-next-line @typescript-eslint/no-var-requires - imports: [forwardRef(() => require("../solvers/solvers.module").SolversModule)], - imports: [forwardRef(() => SolversModule)], // IntentsModule → SorobanModule (IntentsService submits settlement writes) // and SorobanModule → IntentsModule (EventIngestionService reconciles // intents from on-chain events). The cycle is broken with forwardRef. - // SolversModule supplies SolversService to EventIngestionService and, via - // IntentsModule, also participates in the cycle — so it is deferred too. + // SolversModule supplies SolversService to EventIngestionService and also + // participates in the cycle via IntentsModule, so it is deferred too. imports: [forwardRef(() => IntentsModule), forwardRef(() => SolversModule)], - controllers: [SorobanController], - // `forwardRef` is required on both sides: EventIngestionService reads an - // Intent back to date its confirmation metric, so SorobanModule needs - // IntentsModule, and IntentsModule already needs ShadowService from here. - imports: [forwardRef(() => IntentsModule), SolversModule], controllers: [SorobanController, ShadowController], providers: [ SorobanService, diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index e5b3b6a..8083cfe 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -35,8 +35,12 @@ export class SorobanService { * `closeTime` here is what makes `vortex_event_ingestion_lag_seconds` a real * measurement rather than a guess. */ - getLedger(sequence: number) { - return this.server.getLedger(sequence); + getLedger(sequence: number): Promise<{ header?: { closeTime?: string | number } }> { + return ( + this.server as unknown as { + getLedger: (seq: number) => Promise<{ header?: { closeTime?: string | number } }>; + } + ).getLedger(sequence); } getEvents(request: SorobanRpc.Server.GetEventsRequest) { diff --git a/src/soroban/stellar-tx.service.spec.ts b/src/soroban/stellar-tx.service.spec.ts index 8818247..1ac92d6 100644 --- a/src/soroban/stellar-tx.service.spec.ts +++ b/src/soroban/stellar-tx.service.spec.ts @@ -13,6 +13,8 @@ import { 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"; @@ -70,7 +72,11 @@ const CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; function validityWindowSeconds(transaction: Transaction): number { const bounds = transaction.timeBounds; if (!bounds) throw new Error("expected the simulation envelope to carry a validity window"); - return Number(bounds.maxTime) - Number(bounds.minTime); + const min = Number(bounds.minTime); + const max = Number(bounds.maxTime); + // Current stellar-sdk setTimeout() stores minTime=0 and maxTime=now+seconds. + const start = min === 0 ? Math.floor(Date.now() / 1000) : min; + return max - start; } describe("StellarTxService", () => { @@ -92,7 +98,10 @@ describe("StellarTxService", () => { killSwitch = { evaluateTarget: jest.fn().mockReturnValue(notPaused) }; service = new StellarTxService( sorobanService as unknown as SorobanService, + {} as SignerService, + {} as TxConfirmationService, configService as unknown as ConfigService, + undefined, killSwitch as unknown as KillSwitchService, ); }); @@ -163,7 +172,10 @@ describe("StellarTxService", () => { const dryRunService = new StellarTxService( sorobanService as unknown as SorobanService, + {} as SignerService, + {} as TxConfirmationService, dryRunConfigService, + undefined, killSwitch as unknown as KillSwitchService, ); @@ -189,9 +201,19 @@ describe("StellarTxService", () => { }), } as unknown as ConfigService; + const signer = { + withNextSequence: jest.fn(async () => { + throw new Error("live submission attempted"); + }), + getPublicKey: () => "GTEST", + getNetworkPassphrase: () => Networks.TESTNET, + }; const liveService = new StellarTxService( sorobanService as unknown as SorobanService, + signer as unknown as SignerService, + {} as TxConfirmationService, liveConfigService, + undefined, killSwitch as unknown as KillSwitchService, ); @@ -201,7 +223,8 @@ describe("StellarTxService", () => { method: "create_intent", args: [], }), - ).rejects.toThrow(/not yet implemented/); + ).rejects.toThrow(/live submission attempted/); + expect(signer.withNextSequence).toHaveBeenCalled(); }); }); @@ -243,7 +266,12 @@ describe("StellarTxService", () => { } as unknown as ConfigService; return { - service: new StellarTxService(soroban as unknown as SorobanService, configService), + service: new StellarTxService( + soroban as unknown as SorobanService, + {} as SignerService, + {} as TxConfirmationService, + configService, + ), soroban, }; } @@ -318,7 +346,7 @@ describe("StellarTxService", () => { it("classifies a hard failure as a contract error", async () => { const { service, soroban } = buildShadowService(); soroban.simulateTransaction.mockResolvedValue( - simulationError("HostError: Error(WasmVm, InvalidAction) missing export"), + simulationError("HostError: missing export"), ); const result = await service.simulateContract(params()); @@ -365,7 +393,7 @@ describe("StellarTxService", () => { await service.simulateContract(params()); const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect((submitted as Transaction).source.sequenceNumber()).toBe("42"); + expect(String((submitted as Transaction).sequence)).toBe("43"); expect(soroban.getLatestLedger).not.toHaveBeenCalled(); }); @@ -378,7 +406,7 @@ describe("StellarTxService", () => { const [submitted] = soroban.simulateTransaction.mock.calls[0]; // 500 (latest closed) + 1: the next sequence the account would hold. - expect((submitted as Transaction).source.sequenceNumber()).toBe("501"); + expect(String((submitted as Transaction).sequence)).toBe("502"); }); it("falls back to sequence 0 when neither the account nor the ledger can be read", async () => { @@ -391,7 +419,7 @@ describe("StellarTxService", () => { expect(result.outcome).toBe("ok"); const [submitted] = soroban.simulateTransaction.mock.calls[0]; - expect((submitted as Transaction).source.sequenceNumber()).toBe("0"); + expect(String((submitted as Transaction).sequence)).toBe("1"); }); it("builds a single, well-formed host-function operation for the named method", async () => { @@ -402,12 +430,8 @@ describe("StellarTxService", () => { expect(result.outcome).toBe("ok"); const [submitted] = soroban.simulateTransaction.mock.calls[0]; - const envelope = (submitted as Transaction).toEnvelope(); - expect(envelope.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(() => envelope.toXDR()).not.toThrow(); + expect((submitted as Transaction).operations).toHaveLength(1); + expect(() => (submitted as Transaction).toXDR()).not.toThrow(); }); it("sizes the ledger validity window from the worst-case shadow queue drain", async () => { diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index 7ff3d4e..843adbe 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -38,7 +38,6 @@ import { SorobanDataBuilder, nativeToScVal, Networks, - Operation, SorobanRpc, Transaction, TransactionBuilder, @@ -162,7 +161,7 @@ export class StellarTxService { private readonly confirmationService: TxConfirmationService, configService: ConfigService, @Optional() private readonly metricsService?: MetricsService, - private readonly killSwitch: KillSwitchService, + @Optional() private readonly killSwitch?: KillSwitchService, @Optional() private readonly flags?: FeatureFlagService, ) { this.feePercentile = configService.get("stellar.feePercentile", { infer: true }); @@ -475,7 +474,7 @@ export class StellarTxService { */ private assertOnChainWriteAllowed(method: string): void { try { - assertNotPaused(this.killSwitch, { + 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 @@ -610,18 +609,14 @@ export class StellarTxService { }) .addOperation( Operation.invokeHostFunction({ - func: xdr.HostFunctionType.hostFunctionTypeInvokeContract, - args: [ - contract.toScAddress(), - // The method name is a symbol in the Soroban ABI, not a string. - nativeToScVal(params.method, { type: "symbol" }), - params.args, - // Token the call is denominated in. `native` is XLM; the settlement - // contract's own token is a distinct `ScAddress` entry point. The - // value is irrelevant to a simulation, but it must be a well-formed - // ScVal for the envelope to decode. - nativeToScVal("native", { type: "symbol" }), - ], + func: xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: contract.toScAddress(), + functionName: Buffer.from(params.method), + args: [...params.args, nativeToScVal("native", { type: "symbol" })], + }), + ), + auth: [], }), ) .setTimeout(this.simulationTimeoutSeconds) diff --git a/src/soroban/tx-confirmation.service.ts b/src/soroban/tx-confirmation.service.ts index e8df7ad..1154612 100644 --- a/src/soroban/tx-confirmation.service.ts +++ b/src/soroban/tx-confirmation.service.ts @@ -88,7 +88,7 @@ export class TxConfirmationService { } // FAILED - const errorDetail = (response as { resultXdr?: string }).resultXdr ?? "unknown"; + const errorDetail = (response as unknown as { resultXdr?: string }).resultXdr ?? "unknown"; this.logger.warn( `[tx-confirmation] FAILED hash=${hash} durationMs=${durationMs} detail=${errorDetail}`, ); diff --git a/src/tokens/in-memory-tokens.repository.ts b/src/tokens/in-memory-tokens.repository.ts index fb97373..ed263c8 100644 --- a/src/tokens/in-memory-tokens.repository.ts +++ b/src/tokens/in-memory-tokens.repository.ts @@ -43,7 +43,6 @@ export class InMemoryTokensRepository implements ITokensRepository { const match = this.records.find( (record) => record.address.toLowerCase() === normalizedAddress && record.chain === chainName, - (record) => record.address.toLowerCase() === normalizedAddress && record.chain === chainName, ); return match ? { ...match } : undefined; } diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index eb27c5c..00c8263 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from "@nestjs/common"; import { SUPPORTED_TOKENS, StellarToken } from "./tokens.data"; -import { SUPPORTED_TOKENS, STELLAR_TOKENS, StellarToken } from "./tokens.data"; import { SupportedChain } from "../intents/intents.types"; import { ITokensRepository, TOKENS_REPOSITORY, TokenRecord } from "./tokens.repository"; @@ -62,12 +61,9 @@ export class TokensService { * * Returns `undefined` when no match is found — callers decide how to handle * the "unknown token" case (e.g. fall back to a default priceUSD). - * - * @param chain The source chain (stellar | ethereum | base | …) - * @param address Token contract/address string */ async resolveSrcToken(chain: SupportedChain, address: string): Promise { - const token = await this.repo.findByAddressAndChain(address, chain); + const token = await Promise.resolve(this.repo.findByAddressAndChain(address, chain)); if (!token) return undefined; return { kind: "src", @@ -82,11 +78,9 @@ export class TokensService { /** * Look up a Stellar destination token by contract ID. - * - * Returns `undefined` when no match is found. */ async resolveDstToken(contract: string): Promise { - const token = await this.repo.findByAddressAndChain(contract, "stellar"); + const token = await Promise.resolve(this.repo.findByAddressAndChain(contract, "stellar")); if (!token) return undefined; return { kind: "dst", @@ -102,14 +96,8 @@ export class TokensService { * Like {@link resolveSrcToken} but throws a `BadRequestException` instead of * returning `undefined` when the chain + address does not resolve to a token * in the configured registry (issue #276). - * - * Use this on the write path (intent creation) where an unrecognised token - * must be rejected outright rather than silently stored with no priceUSD. */ - async resolveSrcTokenOrThrow( - chain: SupportedChain, - address: string, - ): Promise { + async resolveSrcTokenOrThrow(chain: SupportedChain, address: string): Promise { const token = await this.resolveSrcToken(chain, address); if (!token) { throw new BadRequestException( @@ -134,7 +122,6 @@ export class TokensService { return token; } - private toApiToken(record: TokenRecord): ApiToken { /** * Normalise a stored {@link TokenRecord} into the public token shape. * @@ -143,7 +130,7 @@ export class TokensService { * Stellar-native — the registry stores every token under `address`, but the * Stellar side of the API has always used `contract`. */ - private toApiToken(record: TokenRecord) { + private toApiToken(record: TokenRecord): ApiToken { return { address: record.address, contract: record.address, @@ -154,38 +141,18 @@ export class TokensService { }; } - getByChain(chain?: string): TokensByChainResponse { - const chainRecords = - chain !== undefined && (chain === "stellar" || chain in SUPPORTED_TOKENS) - ? this.repo.findByChain(chain) - : this.repo.findAll(); - const stellarTokens = chainRecords.filter((record) => record.chain === "stellar"); - - if (chain === "stellar") { - return { tokens: stellarTokens.map((record) => this.toApiToken(record)), chain: "stellar" }; - } - if (chain !== undefined && chain in SUPPORTED_TOKENS) { - return { - tokens: chainRecords - .filter((record) => record.chain === chain) - .map((record) => this.toApiToken(record)), - chain, /** * Return the supported token registry, optionally narrowed to one chain. * * - `chain="stellar"` → `{ tokens: StellarToken[], chain: "stellar" }` * - `chain=` → `{ tokens: Token[], chain }` * - omitted / unknown → `{ tokens: Record, stellarTokens: Token[] }` - * - * An unrecognised chain deliberately falls back to the full registry rather - * than erroring: this endpoint feeds discovery UIs, and a client with a - * stale chain list should see everything, not a 4xx. */ - async getByChain(chain?: string) { + async getByChain(chain?: string): Promise { const requested = chain?.toLowerCase(); if (requested === "stellar") { - const records = await this.repo.findByChain("stellar"); + const records = await Promise.resolve(this.repo.findByChain("stellar")); return { tokens: records.map((record) => this.toApiToken(record)), chain: "stellar", @@ -193,21 +160,15 @@ export class TokensService { } if (requested && requested in SUPPORTED_TOKENS) { - const records = await this.repo.findByChain(requested); + const records = await Promise.resolve(this.repo.findByChain(requested)); return { - tokens: records - .filter((record) => record.chain === requested) - .map((record) => this.toApiToken(record)), + tokens: records.filter((record) => record.chain === requested).map((record) => this.toApiToken(record)), chain: requested, }; } - const all = await this.repo.findAll(); - - // Bucket by chain, pre-seeding a key for every chain the static registry - // declares so a chain with no rows still appears as an empty array rather - // than vanishing from the response shape. - const byChain: Record[]> = {}; + const all = await Promise.resolve(this.repo.findAll()); + const byChain: Record = {}; for (const key of Object.keys(SUPPORTED_TOKENS)) { byChain[key] = []; } @@ -217,29 +178,13 @@ export class TokensService { } return { - tokens: Object.fromEntries( - Object.entries(SUPPORTED_TOKENS).map(([key, _]) => [ - key, - chainRecords - .filter((record) => record.chain === key) - .map((record) => this.toApiToken(record)), - ]), - ), - stellarTokens: stellarTokens.map((record) => this.toApiToken(record)), - }; - } - - getStellarTokens(): { tokens: StellarToken[] } { - const records = this.repo.findByChain("stellar"); tokens: byChain, - stellarTokens: all - .filter((record) => record.chain === "stellar") - .map((record) => this.toApiToken(record)), + stellarTokens: all.filter((record) => record.chain === "stellar").map((record) => this.toApiToken(record)), }; } async getStellarTokens(): Promise<{ tokens: StellarToken[] }> { - const records = await this.repo.findByChain("stellar"); + const records = await Promise.resolve(this.repo.findByChain("stellar")); return { tokens: records.map((record) => ({ contract: record.address, diff --git a/src/treasury/treasury.service.spec.ts b/src/treasury/treasury.service.spec.ts index b223f9f..8688ce0 100644 --- a/src/treasury/treasury.service.spec.ts +++ b/src/treasury/treasury.service.spec.ts @@ -6,7 +6,7 @@ import { SorobanService } from "../soroban/soroban.service"; describe("TreasuryService", () => { let service: TreasuryService; - let prisma: jest.Mocked; + let prisma: any; let soroban: jest.Mocked; let configService: jest.Mocked; @@ -52,7 +52,7 @@ describe("TreasuryService", () => { }).compile(); service = module.get(TreasuryService); - prisma = module.get(PrismaService) as jest.Mocked; + prisma = module.get(PrismaService) as any; soroban = module.get(SorobanService) as jest.Mocked; configService = module.get(ConfigService) as jest.Mocked; }); diff --git a/src/treasury/treasury.service.ts b/src/treasury/treasury.service.ts index a9ac6e9..e512df9 100644 --- a/src/treasury/treasury.service.ts +++ b/src/treasury/treasury.service.ts @@ -164,11 +164,10 @@ export class TreasuryService { // Handle issued assets (traditional Stellar assets) const [code, issuer] = asset.split(":"); if (issuer) { - const balance = account.balances.find( - (b) => b.asset_type !== "native" && - b.asset_code === code && - b.asset_issuer === issuer, - ); + const balance = account.balances.find((b) => { + if (b.asset_type === "native" || b.asset_type === "liquidity_pool_shares") return false; + return b.asset_code === code && b.asset_issuer === issuer; + }); return { asset, balance: balance ? this.parseBalance(balance.balance) : "0", diff --git a/test/__mocks__/@stellar/stellar-sdk.ts b/test/__mocks__/@stellar/stellar-sdk.ts index 81a3b0f..86cd222 100644 --- a/test/__mocks__/@stellar/stellar-sdk.ts +++ b/test/__mocks__/@stellar/stellar-sdk.ts @@ -1,16 +1,3 @@ -import * as path from "path"; - -// The e2e moduleNameMapper maps the bare specifier "^@stellar/stellar-sdk$" to -// this file (jest.requireActual still goes through moduleNameMapper, so it -// cannot be used here). Resolve the real package entry by absolute path -// instead — an absolute path does not match the mapper regex, so the genuine -// SDK is loaded and re-exported below (only SorobanRpc.Server is replaced). -/* eslint-disable @typescript-eslint/no-var-requires */ -const actual = require(path.resolve( - __dirname, - "../../../node_modules/@stellar/stellar-sdk/lib/index.js", -)) as typeof import("@stellar/stellar-sdk"); -/* eslint-enable @typescript-eslint/no-var-requires */ /** * Hermetic test double for `@stellar/stellar-sdk`. * @@ -33,13 +20,13 @@ const actual = require(path.resolve( * 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( - path.join(__dirname, "..", "..", "..", "node_modules", "@stellar", "stellar-sdk", "lib", "index.js"), +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ +const actual: any = require( + path.resolve(__dirname, "../../../node_modules/@stellar/stellar-sdk/lib/index.js"), ); +/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ const mockServer = { getHealth: jest.fn().mockResolvedValue({ status: "ok" }), @@ -72,58 +59,29 @@ const mockServer = { const mockServerClass = jest.fn().mockImplementation(() => mockServer); +function isSimulationError(response: unknown): boolean { + return Boolean( + response && + typeof response === "object" && + "error" in (response as Record) && + (response as Record).error != null, + ); +} + +const stubbedRpc = { + ...actual.SorobanRpc, + Server: mockServerClass, + Api: { + ...actual.SorobanRpc?.Api, + isSimulationError, + }, +}; + module.exports = { ...actual, - SorobanRpc: { - ...actual.SorobanRpc, - Server: mockServerClass, - }, + SorobanRpc: stubbedRpc, rpc: { ...actual.rpc, Server: mockServerClass, -/** - * 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: jest.fn().mockImplementation(() => mockServer), - 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 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; -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 13047b5..61a197d 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 9676eb3..519b999 100644 --- a/test/cors.e2e-spec.ts +++ b/test/cors.e2e-spec.ts @@ -8,6 +8,7 @@ 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"; @@ -42,7 +43,11 @@ async function createAppWithOrigin(origin: string): Promise { async function createAppWithSecurityHeaders(nodeEnv = "development"): Promise { const previousNodeEnv = process.env.NODE_ENV; + const previousAllowLocal = process.env.ALLOW_LOCAL_SIGNER_IN_PROD; process.env.NODE_ENV = nodeEnv; + // The production signer guard refuses a local keypair. This test only checks + // that Swagger is not mounted; it is not exercising custody policy. + if (nodeEnv === "production") process.env.ALLOW_LOCAL_SIGNER_IN_PROD = "true"; const moduleRef = await Test.createTestingModule({ imports: [AppModule], @@ -72,9 +77,20 @@ async function createAppWithSecurityHeaders(nodeEnv = "development"): Promise { beforeAll(async () => { app = await createTestApp(); + await app.listen(0); httpServer = app.getHttpServer(); }); @@ -164,22 +165,22 @@ describe("DoS / resource-exhaustion limits (issue #476)", () => { .expect(400); }); - it(`accepts exactly ${BATCH_LOOKUP_MAX_IDS} IDs with 200`, async () => { + it(`accepts exactly ${BATCH_LOOKUP_MAX_IDS} IDs with 201`, 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. + // 201 is Nest's default for POST. None of the IDs exist, so the body is empty. await request(httpServer) .post("/api/v1/intents/batch") .send(atLimit) - .expect(200); + .expect(201); }); - it("accepts 1 ID with 200", async () => { + it("accepts 1 ID with 201", async () => { await request(httpServer) .post("/api/v1/intents/batch") .send({ intentIds: ["does-not-exist"] }) - .expect(200); + .expect(201); }); it("rejects a non-array intentIds with 400", async () => { diff --git a/test/intent-expiry.e2e-spec.ts b/test/intent-expiry.e2e-spec.ts index 135dcb6..5547a9f 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 dd523e3..be76b97 100644 --- a/test/intent-lifecycle.e2e-spec.ts +++ b/test/intent-lifecycle.e2e-spec.ts @@ -44,7 +44,7 @@ const BASE_INTENT = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "1980000", + minDstAmount: "19800000", }; /** Shape IntentsService.create() expects (already-resolved token objects). */ @@ -126,7 +126,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 @@ -143,7 +143,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, }) @@ -151,7 +151,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"); @@ -161,7 +161,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"); @@ -169,7 +169,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 e5c8bf6..faaddb2 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)", () => { @@ -141,7 +141,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 @@ -150,13 +150,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"); }); @@ -194,7 +194,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 910ad19..dbe9abb 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -7,6 +7,7 @@ "^.+\\.ts$": ["ts-jest", { "diagnostics": false }] }, "moduleNameMapper": { - "^@stellar/stellar-sdk$": "/test/__mocks__/@stellar/stellar-sdk.ts" + "^@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 40301e1..4788dae 100644 --- a/test/load/concurrent-accept.test.ts +++ b/test/load/concurrent-accept.test.ts @@ -26,7 +26,7 @@ const validCreateBody = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; describe("Concurrent accept / fill race load test", () => { @@ -97,7 +97,7 @@ describe("Concurrent accept / fill race load test", () => { .post(`/api/v1/intents/${intentId}/fill`) .send({ solver: kp.publicKey(), - fillAmount: "995000", + fillAmount: "9950000", txHash: `tx-${Math.random()}`, signature: sign(kp, buildFillMessage(intentId, kp.publicKey())), }), @@ -115,7 +115,7 @@ describe("Concurrent accept / fill race load test", () => { const intent = (await request(app.getHttpServer()).get(`/api/v1/intents/${intentId}`).expect(200)) .body; expect(intent.state).toBe("filled"); - expect(intent.fillAmount).toBe("995000"); + expect(intent.fillAmount).toBe("9950000"); }); it("mixed solvers racing for different intents all resolve with at most one winner each", async () => { @@ -169,7 +169,7 @@ describe("Concurrent accept / fill race load test", () => { const results = Array.from({ length: 50 }, () => repo.fillIfAccepted(open.intentId, "SOLVER_X", { - fillAmount: "995000", + fillAmount: "9950000", txHash: "race-hash", filledAt: Math.floor(Date.now() / 1000), }), @@ -201,7 +201,7 @@ describe("Concurrent accept / fill race load test", () => { const late = await repo.fillIfAccepted( open.intentId, "SOLVER_X", - { fillAmount: "995000", txHash: "late", filledAt: now + 60 }, + { fillAmount: "9950000", txHash: "late", filledAt: now + 60 }, now + 60, ); expect(late).toBeNull(); diff --git a/test/load/concurrent-idempotent-create.test.ts b/test/load/concurrent-idempotent-create.test.ts index 0e2b906..5a693fa 100644 --- a/test/load/concurrent-idempotent-create.test.ts +++ b/test/load/concurrent-idempotent-create.test.ts @@ -22,7 +22,7 @@ const validCreateBody = { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }; describe("Concurrent idempotent create race load test", () => { @@ -30,6 +30,7 @@ describe("Concurrent idempotent create race load test", () => { beforeAll(async () => { app = await createTestApp(); + await app.listen(0); }); afterAll(async () => { @@ -67,12 +68,14 @@ describe("Concurrent idempotent create race load test", () => { it("still creates distinct intents for concurrent calls with different keys", async () => { const concurrency = 10; + // A fresh user: the previous case spends the per-user create budget. + const user = "GRACETESTUSER7654321"; const results = await Promise.all( Array.from({ length: concurrency }, () => request(app.getHttpServer()) .post("/api/v1/intents") - .send({ ...validCreateBody, idempotencyKey: randomUUID() }) + .send({ ...validCreateBody, user, idempotencyKey: randomUUID() }) .expect(201), ), ); diff --git a/test/load/ws-broadcast-fanout.test.ts b/test/load/ws-broadcast-fanout.test.ts index 4bb8119..a8b162a 100644 --- a/test/load/ws-broadcast-fanout.test.ts +++ b/test/load/ws-broadcast-fanout.test.ts @@ -22,6 +22,11 @@ import WebSocket from "ws"; import { createTestApp } from "../utils/create-test-app"; import { IntentsGateway } from "../../src/intents/intents.gateway"; +// Fan-out opens hundreds of sockets from one loopback address. The production +// per-IP cap (default 20) would reject the rest of the tier. +process.env.WS_MAX_CONNECTIONS_PER_IP = "10000"; +process.env.WS_MAX_CONNECTIONS = "10000"; + // ── tunables ────────────────────────────────────────────────────────────────── /** Subscriber counts to exercise. Each tier runs as a separate test case. */ @@ -195,6 +200,8 @@ describe("WS broadcast fan-out load test (#84)", () => { afterAll(async () => { await app.close(); + delete process.env.WS_MAX_CONNECTIONS_PER_IP; + delete process.env.WS_MAX_CONNECTIONS; }, 15_000); for (const subscriberCount of SUBSCRIBER_TIERS) { 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 3ea0f7c..6a197dc 100644 --- a/test/params.e2e-spec.ts +++ b/test/params.e2e-spec.ts @@ -5,7 +5,7 @@ * against a real booted Nest app (no contract configured → code defaults). */ -import * as request from "supertest"; +import request from "supertest"; import { INestApplication } from "@nestjs/common"; import { createTestApp } from "./utils/create-test-app"; 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 614b8f4..bfadb7e 100644 --- a/test/stats.e2e-spec.ts +++ b/test/stats.e2e-spec.ts @@ -45,7 +45,7 @@ describe("StatsController (e2e)", () => { dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", dstTokenSymbol: "USDC", dstTokenDecimals: 7, - minDstAmount: "990000", + minDstAmount: "9900000", }) .expect(201); const intentId = createRes.body.intentId as string; @@ -59,13 +59,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 c8d97ff..0836800 100644 --- a/test/utils/create-test-app.ts +++ b/test/utils/create-test-app.ts @@ -3,8 +3,8 @@ import { Test } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; import { WsAdapter } from "@nestjs/platform-ws"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import { json } from "express"; import { json, Request, Response, NextFunction } from "express"; +import helmet from "helmet"; import { AppModule } from "../../src/app.module"; import { AppConfig } from "../../src/config/configuration"; import { HttpExceptionFilter } from "../../src/common/http-exception.filter"; @@ -42,6 +42,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 c6a04e9..3d05048 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)", () => {