Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,20 @@ EGRESS_MAX_BODY_SIZE_BYTES=10485760
SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org
WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com
ORACLE_ALLOWLIST=oracle.trusted.io
# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points.
MAX_USER_SLIPPAGE_BPS=100
MAX_PREMIUM_BPS=50
ORACLE_FAIL_OPEN_MAX_USD=100
ORACLE_MAX_STALENESS_MS=60000
# Public anonymised datasets (RFC 0001). Disabled until an operator opts in.
DATASETS_ENABLED=false
DATASETS_ANONYMIZE=true
DATASETS_SALT=
DATASETS_SALT_ROTATION_HOURS=24
DATASETS_SALT_RETENTION_WINDOWS=2
DATASETS_PUBLIC_BUCKET=vortex-public-datasets
DATASETS_STORAGE_KIND=memory
DATASETS_LOCAL_DIR=./data/datasets
# ─── WS gateway hardening (issue #455) ───────────────────────────────────────
# Inbound frames larger than this close the socket (1009).
WS_MAX_PAYLOAD_BYTES=16384
Expand Down
14 changes: 14 additions & 0 deletions .env.mainnet.example
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ EGRESS_MAX_BODY_SIZE_BYTES=10485760
SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org
WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com
ORACLE_ALLOWLIST=oracle.trusted.io
# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points.
MAX_USER_SLIPPAGE_BPS=100
MAX_PREMIUM_BPS=50
ORACLE_FAIL_OPEN_MAX_USD=100
ORACLE_MAX_STALENESS_MS=60000
# Public anonymised datasets (RFC 0001). Disabled until an operator opts in.
DATASETS_ENABLED=false
DATASETS_ANONYMIZE=true
DATASETS_SALT=
DATASETS_SALT_ROTATION_HOURS=24
DATASETS_SALT_RETENTION_WINDOWS=2
DATASETS_PUBLIC_BUCKET=vortex-public-datasets
DATASETS_STORAGE_KIND=memory
DATASETS_LOCAL_DIR=./data/datasets
# ─── WS gateway hardening (issue #455) ───────────────────────────────────────
# Inbound frames larger than this close the socket (1009).
WS_MAX_PAYLOAD_BYTES=16384
Expand Down
5 changes: 5 additions & 0 deletions .env.staging.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ GUARDIAN_CONTRACT_ID=
# ─── Synthetic canary (issue #496) ───────────────────────────────────────────
# Canary user + solver addresses; excluded from public stats and leaderboards.
CANARY_ADDRESSES=
# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points.
MAX_USER_SLIPPAGE_BPS=100
MAX_PREMIUM_BPS=50
ORACLE_FAIL_OPEN_MAX_USD=100
ORACLE_MAX_STALENESS_MS=60000

# Public anonymised datasets (docs/rfcs/0001)
# Master switch for the public dataset publication job.
Expand Down
198 changes: 198 additions & 0 deletions .env.testnet.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# .env.testnet.example
#
# Environment template for LOCAL DEVELOPMENT against Stellar TESTNET.
# Copy to .env and fill in any values marked with <CHANGE_ME>.
#
# cp .env.testnet.example .env
#
# Testnet is safe to experiment with — tokens have no real value and contract
# deployments are free via Friendbot. Never reuse testnet keys on mainnet.
#
# Closes #136

# ─── Database ────────────────────────────────────────────────────────────────
# Local Docker Compose default. Adjust if you use a remote or managed DB.
DATABASE_URL=postgresql://vortex:vortex@localhost:5432/vortex?schema=public

# ─── Server ──────────────────────────────────────────────────────────────────
PORT=4000
NODE_ENV=development

# ─── Stellar / Soroban ───────────────────────────────────────────────────────
STELLAR_NETWORK=testnet
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Testnet contract IDs — leave blank until you have deployed contracts.
# The service boots without them; on-chain write paths are no-ops when empty.
SETTLEMENT_CONTRACT_ID=
SOLVER_REGISTRY_CONTRACT_ID=

# Testnet signing key — generate a throwaway keypair, fund it with Friendbot,
# and paste the secret seed here. Never reuse this key on mainnet.
#
# # Generate a new key:
# npx @stellar/stellar-cli keys generate local-dev --network testnet
# npx @stellar/stellar-cli keys show local-dev
#
# # Or via the SDK:
# node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())"
#
# # Fund it (testnet only):
# curl "https://friendbot.stellar.org/?addr=<YOUR_PUBLIC_KEY>"
#
# Optional in development — leave blank to skip on-chain writes.
SOROBAN_SIGNING_KEY=

# Fee percentile used when estimating Soroban inclusion fees.
# p50 is a safe default for testnet; raise to p90+ for time-sensitive mainnet txs.
SOROBAN_FEE_PERCENTILE=p50

# ─── CORS ────────────────────────────────────────────────────────────────────
# Wildcard is fine for local development — tighten this in staging/production.
CORS_ORIGIN=*

# ─── WebSocket ───────────────────────────────────────────────────────────────
WS_MAX_CONNECTIONS=1000

# ─── Pluggable signer backend (issue #400) ───────────────────────────────────
# SIGNER_BACKEND=local is the default for development.
# In production use SIGNER_BACKEND=vault and supply VAULT_ADDR + VAULT_TOKEN.
SIGNER_BACKEND=local
VAULT_ADDR=
VAULT_TOKEN=
VAULT_TRANSIT_KEY_NAME=vortex-signer
ALLOW_LOCAL_SIGNER_IN_PROD=false
# ─── Resource-exhaustion limits (issue #476) ─────────────────────────────────
# Maximum JSON nesting depth — rejects deeply-nested body attacks (default 10).
JSON_MAX_DEPTH=10
# Maximum chain values in a single WS subscribe message (default 20).
WS_MAX_FILTER_CHAINS=20
# Maximum active subscriptions per WS connection (default 10).
WS_MAX_SUBSCRIPTIONS=10
# Postgres statement_timeout for standard queries in ms (default 5000).
DB_QUERY_TIMEOUT_MS=5000
# Postgres statement_timeout for batch queries in ms (default 10000).
DB_BATCH_QUERY_TIMEOUT_MS=10000
# Postgres statement_timeout for stats queries in ms (default 15000).
DB_STATS_QUERY_TIMEOUT_MS=15000

# Emergency kill-switch (issue #477)
# Postgres-backed so a pause survives a restart and reaches every replica.
KILLSWITCH_OPERATOR_TOKEN=
KILLSWITCH_REDIS_URL=
KILLSWITCH_POLL_MS=2000
KILLSWITCH_PERSISTENCE=prisma

# ─── Observability (optional) ────────────────────────────────────────────────
# Leave blank to disable Sentry error reporting.
SENTRY_DSN=

# debug | info | warn | error (defaults to "debug" in development)
LOG_LEVEL=debug

# ── Shadow-mode divergence monitor (issue #401) ─────────────────────────
# Off by default in every environment. It runs read-only `simulateTransaction`
# calls against SETTLEMENT_CONTRACT_ID in parallel with the off-chain intent
# path and never signs or submits anything.
#
# SHADOW_SOURCE_ACCOUNT only has to be a valid Stellar public key: it is used to
# populate the source-account field of the simulated envelope and is never
# signed, never charged a fee and never broadcast. It must still be set, or
# every transition reports "contract_unconfigured".
SHADOW_MODE_ENABLED=false
SHADOW_SAMPLE_RATE=1
SHADOW_QUEUE_MAX=256
SHADOW_CONCURRENCY=4
SHADOW_SOURCE_ACCOUNT=
# ─── Governance / Protocol Parameters ────────────────────────────────────────
# On-chain governance parameters contract ID — leave blank to use code defaults.
PARAMS_CONTRACT_ID=

# Poll interval in ms. 30 000 is fine for testnet.
PARAMS_POLL_INTERVAL_MS=30000
# ─── Leader election ─────────────────────────────────────────────────────────
# Enable for multi-replica testnet deployments.
LEADER_ELECTION_ENABLED=false
LEADER_ELECTION_HEARTBEAT_MS=5000

# ─── Background jobs (issue #494) ────────────────────────────────────────────
# api | worker | all — queue workers only run in "worker" or "all".
PROCESS_ROLE=all
# memory (single-process, dev/test) | bullmq (Redis-backed, uses REDIS_URL)
JOBS_DRIVER=memory
# Grace period for in-flight jobs on SIGTERM before they are returned to the queue.
JOBS_SHUTDOWN_TIMEOUT_MS=25000

# ─── Runtime feature flags (issue #495) ──────────────────────────────────────
# Change propagation across instances: memory (single instance) | redis
FLAGS_PUBSUB=memory
# Safety-net cache reload interval (ms)
FLAGS_REFRESH_MS=30000
# Break-glass pins that win over DB state, e.g. onchain-dry-run=true
FLAG_OVERRIDES=

# ─── Admin RBAC ──────────────────────────────────────────────────────────────
# Comma-separated id:role:secret (role = admin | superadmin, secret >= 16 chars).
# Sent as the x-admin-key header (the secret part). Empty disables admin APIs.
ADMIN_API_KEYS=

# ─── Guardian emergency ingestion (issue #507) ───────────────────────────────
# Guardian / security-council contract ID. Leave blank to disable ingestion.
GUARDIAN_CONTRACT_ID=

# ─── Synthetic canary (issue #496) ───────────────────────────────────────────
# Canary user + solver addresses; excluded from public stats and leaderboards.
CANARY_ADDRESSES=
# Egress/SSRF Protection
EGRESS_TIMEOUT_MS=10000
EGRESS_MAX_REDIRECTS=3
EGRESS_MAX_BODY_SIZE_BYTES=10485760
SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org
WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com
ORACLE_ALLOWLIST=oracle.trusted.io
# Oracle minDstAmount gates (issue #434). Slippage/premium in basis points.
MAX_USER_SLIPPAGE_BPS=100
MAX_PREMIUM_BPS=50
ORACLE_FAIL_OPEN_MAX_USD=100
ORACLE_MAX_STALENESS_MS=60000
# Public anonymised datasets (RFC 0001). Disabled until an operator opts in.
DATASETS_ENABLED=false
DATASETS_ANONYMIZE=true
DATASETS_SALT=
DATASETS_SALT_ROTATION_HOURS=24
DATASETS_SALT_RETENTION_WINDOWS=2
DATASETS_PUBLIC_BUCKET=vortex-public-datasets
DATASETS_STORAGE_KIND=memory
DATASETS_LOCAL_DIR=./data/datasets
# ─── WS gateway hardening (issue #455) ───────────────────────────────────────
# Inbound frames larger than this close the socket (1009).
WS_MAX_PAYLOAD_BYTES=16384
# Concurrent WS connections per client IP (0 = unlimited).
WS_MAX_CONNECTIONS_PER_IP=20
# Trusted reverse-proxy hops for X-Forwarded-For (0 = socket address only).
WS_TRUST_PROXY_HOPS=0
# Inbound token bucket per connection; repeat violators are disconnected.
WS_RATE_LIMIT_PER_SEC=10
WS_RATE_LIMIT_BURST=20
WS_RATE_LIMIT_MAX_VIOLATIONS=5
# Outbound backpressure: messages held per slow consumer, socket buffer
# threshold (bytes), and what to do when the queue is full.
WS_OUTBOUND_QUEUE_MAX=1000
WS_OUTBOUND_BUFFER_BYTES=1048576
WS_SLOW_CONSUMER_POLICY=drop_oldest
# HS256 secret for solver JWTs from the SEP-10 auth flow (#442); >= 32 chars.
# Empty disables JWT auth on the WS gateway.
AUTH_JWT_SECRET=

# ─── Health probes (issue #492) ──────────────────────────────────────────────
# Roles served by this process (api, ws, worker); readiness checks follow them.
SERVICE_ROLES=api,ws,worker
HEALTH_CHECK_INTERVAL_MS=5000
# Readiness hysteresis: failures before not-ready, successes before ready again.
HEALTH_READY_FAILURE_THRESHOLD=3
HEALTH_READY_SUCCESS_THRESHOLD=2
# Liveness fails when event-loop delay exceeds this.
HEALTH_EVENT_LOOP_MAX_LAG_MS=1000
# Soroban RPC endpoints for the quorum check (default: SOROBAN_RPC_URL).
SOROBAN_RPC_HEALTH_URLS=

31 changes: 30 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s
## [Unreleased]

### Added
- Oracle-referenced `minDstAmount` validation on intent create: fair destination
value from the aggregator, rejection of slippage above `MAX_USER_SLIPPAGE_BPS`
unless the user signs `acknowledgeHighSlippage`, rejection of premium above
`MAX_PREMIUM_BPS`, and fail-open/fail-closed oracle policy
(Closes #434)
- Transactional outbox for on-chain writes: `onchain_outbox` table, intent change + outbox
row committed in one Prisma transaction, `OutboxRelayService` (SKIP LOCKED claims, per-intent
ordering, envelope hash persisted before submit, dead-lettering with alert),
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ GET /api/v1/intents — list intents (filter by state, user, chain
GET /api/v1/intents/open — all open intents (solver view)
GET /api/v1/intents/:id — single intent
GET /api/v1/intents/user/:addr — intents for a user
POST /api/v1/intents — create intent
POST /api/v1/intents — create intent (oracle-checked minDstAmount; 201 includes fairValue + slippageBps)
POST /api/v1/intents/:id/accept — solver accepts
POST /api/v1/intents/:id/fill — solver fills
POST /api/v1/intents/:id/cancel — user cancels
Expand Down Expand Up @@ -163,6 +163,10 @@ from those that are safe to leave at their testnet/dev defaults.
| `LEADER_ELECTION_ENABLED` | Recommended (multi-replica) | `false` | Set to `true` when running N > 1 replicas to ensure singleton workers run on exactly one pod. Requires `DATABASE_URL` to point at a live Postgres instance. **Do not use PgBouncer in transaction-pooling mode** — see [Leader Election runbook](./docs/runbooks/leader-election.md). |
| `LEADER_ELECTION_HEARTBEAT_MS` | Optional | `5000` | Heartbeat interval in ms. Lower = faster failover, higher DB load. Default gives ≤ 15 s failover. |
| `PORT` | Optional | `4000` | Change if the container port mapping differs |
| `MAX_USER_SLIPPAGE_BPS` | Optional | `100` | Max user slippage vs oracle fair `minDstAmount` (1% default). Higher slippage requires a signed `acknowledgeHighSlippage`. |
| `MAX_PREMIUM_BPS` | Optional | `50` | Max `minDstAmount` premium above oracle fair value; always rejected above this. |
| `ORACLE_FAIL_OPEN_MAX_USD` | Optional | `100` | When oracle prices are missing/stale, intents with source notional at or below this USD amount are still created. |
| `ORACLE_MAX_STALENESS_MS` | Optional | `60000` | Price snapshots older than this are treated as unavailable. |

For a production `.env` template, copy `.env.mainnet.example` — every
`<CHANGE_ME>` value corresponds to a "required for production" row above.
Expand Down
38 changes: 38 additions & 0 deletions docs/adr/0003-oracle-min-dst-validation.md
Original file line number Diff line number Diff line change
@@ -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:<user>:<srcAmount>:<minDstAmount>`.
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`.
1 change: 1 addition & 0 deletions docs/rate-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<user>:<srcAmount>:<minDstAmount>` | Required only when `acknowledgeHighSlippage` is true; signed by the intent `user` |
| `POST /api/v1/intents/:id/accept` | Accept | `accept:<intentId>:<solver>` | Valid solver signature |
| `POST /api/v1/intents/:id/fill` | Fill | `fill:<intentId>:<solver>` | Valid solver signature |
| `POST /api/v1/intents/:id/cancel` | Cancel | `cancel:<intentId>` | Valid user signature |
Expand Down
Loading
Loading