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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ SOROBAN_FEE_PERCENTILE=p50
INTENT_RETENTION_DAYS=30
INTENT_RETENTION_SWEEP_MS=60000

# Time allowed to collect connected solver RFQ responses (1-1000 ms).
QUOTE_AUCTION_WINDOW_MS=300

# ─── CORS ────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins for the frontend.
# Development default: "*" (any origin allowed — convenient for local work)
Expand Down
1 change: 1 addition & 0 deletions .env.mainnet.example
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ CORS_ORIGIN=https://app.vortex.trade # <CHANGE_ME>
# ─── WebSocket ───────────────────────────────────────────────────────────────
# Tune based on expected solver + frontend connection count.
WS_MAX_CONNECTIONS=5000
QUOTE_AUCTION_WINDOW_MS=300

# ─── Pluggable signer backend (issue #400) ───────────────────────────────────
# REQUIRED in production: use SIGNER_BACKEND=vault so the signing key never
Expand Down
1 change: 1 addition & 0 deletions .env.staging.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ SOROBAN_FEE_PERCENTILE=p50

CORS_ORIGIN=*
WS_MAX_CONNECTIONS=1000
QUOTE_AUCTION_WINDOW_MS=300

# ─── Pluggable signer backend (issue #400) ───────────────────────────────────
SIGNER_BACKEND=local
Expand Down
147 changes: 147 additions & 0 deletions .env.testnet.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ NODE_ENV=development

# ─── Stellar / Soroban ───────────────────────────────────────────────────────
STELLAR_NETWORK=testnet
INTENTS_PERSISTENCE=prisma
ETHEREUM_RPC_URL=
ETHEREUM_ESCROW_ADDRESS=
BASE_RPC_URL=
BASE_ESCROW_ADDRESS=
POLYGON_RPC_URL=
POLYGON_ESCROW_ADDRESS=
ARBITRUM_RPC_URL=
ARBITRUM_ESCROW_ADDRESS=
OPTIMISM_RPC_URL=
OPTIMISM_ESCROW_ADDRESS=
AVALANCHE_RPC_URL=
AVALANCHE_ESCROW_ADDRESS=
EVM_RPC_ALLOWLIST=
ALLOW_LEGACY_STELLAR_SIGNATURES=false
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Testnet contract IDs — leave blank until you have deployed contracts.
Expand Down Expand Up @@ -53,6 +68,7 @@ CORS_ORIGIN=*

# ─── WebSocket ───────────────────────────────────────────────────────────────
WS_MAX_CONNECTIONS=1000
QUOTE_AUCTION_WINDOW_MS=300

# ─── Pluggable signer backend (issue #400) ───────────────────────────────────
# SIGNER_BACKEND=local is the default for development.
Expand Down Expand Up @@ -120,3 +136,134 @@ PARAMS_POLL_INTERVAL_MS=30000
LEADER_ELECTION_ENABLED=false
LEADER_ELECTION_HEARTBEAT_MS=5000

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

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

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

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

# ─── Synthetic canary (issue #496) ───────────────────────────────────────────
# Canary user + solver addresses; excluded from public stats and leaderboards.
CANARY_ADDRESSES=

# Public anonymised datasets (docs/rfcs/0001)
# Master switch for the public dataset publication job.
DATASETS_ENABLED=false
# Hash user addresses with the rotating salt before export.
DATASETS_ANONYMIZE=true
# Base anonymisation salt. Required (>= 32 chars) when datasets are enabled
# and anonymisation is on; generate with `openssl rand -hex 32`.
DATASETS_SALT=
# How often the anonymisation salt rotates, in hours.
DATASETS_SALT_ROTATION_HOURS=24
# How many previous salt windows are retained for continuity.
DATASETS_SALT_RETENTION_WINDOWS=2
# Public bucket/prefix the published datasets live under.
DATASETS_PUBLIC_BUCKET=vortex-public-datasets
# Storage backend: local (writes to disk) | memory (tests only).
DATASETS_STORAGE=local
# Root directory for the local storage backend.
DATASETS_LOCAL_DIR=.datasets

# Secrets Manager (issue #465)
# Provider: env | aws-secrets-manager | vault-kv
SECRETS_PROVIDER=env
# Poll interval for secret rotation (ms)
SECRETS_REFRESH_INTERVAL_MS=60000
# Extra secrets: comma-separated "name:envVar:required"
SECRETS_EXTRA=

# AWS Secrets Manager
AWS_SECRETS_MANAGER_PREFIX=
AWS_SECRETS_MANAGER_POLL_INTERVAL_MS=60000

# Vault KV
VAULT_KV_MOUNT=secret
VAULT_KV_PREFIX=vortex/
VAULT_KV_POLL_INTERVAL_MS=60000

# Extra secret env vars referenced by the default SecretConfig
JWT_SIGNING_KEY=
WEBHOOK_SECRET=
CHANNEL_KEY=

# Egress/SSRF Protection
EGRESS_TIMEOUT_MS=10000
EGRESS_MAX_REDIRECTS=3
EGRESS_MAX_BODY_SIZE_BYTES=10485760
SOROBAN_RPC_ALLOWLIST=soroban-testnet.stellar.org,soroban-rpc.stellar.org
WEBHOOK_ALLOWLIST=hooks.example.com,hooks.trusted.com
ORACLE_ALLOWLIST=oracle.trusted.io
# ─── WS gateway hardening (issue #455) ───────────────────────────────────────
# Inbound frames larger than this close the socket (1009).
WS_MAX_PAYLOAD_BYTES=16384
# Concurrent WS connections per client IP (0 = unlimited).
WS_MAX_CONNECTIONS_PER_IP=20
# Trusted reverse-proxy hops for X-Forwarded-For (0 = socket address only).
WS_TRUST_PROXY_HOPS=0
# Inbound token bucket per connection; repeat violators are disconnected.
WS_RATE_LIMIT_PER_SEC=10
WS_RATE_LIMIT_BURST=20
WS_RATE_LIMIT_MAX_VIOLATIONS=5
# Outbound backpressure: messages held per slow consumer, socket buffer
# threshold (bytes), and what to do when the queue is full.
WS_OUTBOUND_QUEUE_MAX=1000
WS_OUTBOUND_BUFFER_BYTES=1048576
WS_SLOW_CONSUMER_POLICY=drop_oldest
# HS256 secret for solver JWTs from the SEP-10 auth flow (#442); >= 32 chars.
# Empty disables JWT auth on the WS gateway.
AUTH_JWT_SECRET=

# ─── API keys & distributed rate limiting (issue #441) ─────────────────────────
RATE_LIMIT_LOCAL_PRUNE_MS=60000
# Redis URL for the shared rate-limit window. Empty = bounded local limiter.
RATE_LIMIT_REDIS_URL=

# ─── Scoped solver credentials (issue #443) ───────────────────────────────────
CREDENTIAL_REVOCATION_PUBSUB=memory

# ─── SSE intent feed (issue #433) ─────────────────────────────────────────────
SSE_HEARTBEAT_MS=15000
SSE_MAX_BUFFER_BYTES=1048576

# ─── Public anonymised datasets ──────────────────────────────────────────────
DATASETS_ENABLED=false
DATASETS_ANONYMIZE=true
DATASETS_SALT=
DATASETS_SALT_ROTATION_HOURS=24
DATASETS_SALT_RETENTION_WINDOWS=2
DATASETS_PUBLIC_BUCKET=
DATASETS_STORAGE_KIND=memory
DATASETS_LOCAL_DIR=

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

32 changes: 32 additions & 0 deletions docs/solver-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,38 @@ Upon subscription, the WebSocket server responds with a `subscribed` event:
```
Subsequent `intent_created` events will only be broadcast to the bot if the intent's `srcChain` matches one of the subscribed chains.

### RFQ Quote Requests
Authenticated, active solvers with a positive bond and matching source-chain/token capabilities may receive a short-lived `rfq_request` over this WebSocket. The default response window is 300 ms and can be configured from 1 to 1,000 ms with `QUOTE_AUCTION_WINDOW_MS`.

```json
{
"type": "rfq_request",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"srcChain": "ethereum",
"srcTokenSymbol": "USDC",
"srcAmount": "1000000",
"dstTokenSymbol": "USDC",
"deadline": 1775836800300
}
```

Reply before `deadline` with the gross destination amount, solver fee, expiry in Unix seconds, and a Stellar Ed25519 signature:

```json
{
"type": "rfq_response",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"dstAmount": "998500",
"fee": "100",
"expiresAt": 1775836860,
"signature": "base64EncodedSignatureString=="
}
```

Sign the UTF-8 bytes of `vortex:rfq:v1:<requestId>:<payloadHash>`. `payloadHash` is the lowercase SHA-256 hex digest of the JSON encoding of the request fields (`requestId`, `srcChain`, `srcTokenSymbol`, `srcAmount`, `dstTokenSymbol`, optional `srcTokenAddress` and `dstTokenContract`, and `deadline`) plus `solver`, `dstAmount`, `fee`, and `expiresAt`. Omit absent optional fields and sort keys lexicographically before `JSON.stringify`. The signature is Base64-encoded. The backend accepts one valid response per solver and request; late, expired, malformed, or invalidly signed responses are ignored.

Quotes are ranked by destination amount after solver and protocol fees, with reputation breaking ties. If no valid solver response arrives within the window, the API returns the existing model-based estimate with `indicative: true`; otherwise `indicative` is false.

### Event Replay & Reconnection
On connection or reconnection, the bot can request event replay from its last received sequence ID (`seq`) to avoid missing intents during network blips:
```json
Expand Down
2 changes: 1 addition & 1 deletion scripts/db-migrate-locked.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* `require` and typed here instead of via an ES import (no allowJs in
* tsconfig, and the runtime must not depend on generated types).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
// eslint-disable-next-line @typescript-eslint/no-var-requires
const migrate = require("./db-migrate-locked.js") as {
CHECKPOINT_DDL: string;
CHECKPOINT_TABLE: string;
Expand Down
15 changes: 15 additions & 0 deletions src/common/rfq-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createHash } from "node:crypto";
import { RfqResponseSignaturePayload } from "../intents/rfq.types";

/** Canonical domain-separated message signed by a solver for an RFQ response. */
export function buildRfqResponseMessage(payload: RfqResponseSignaturePayload): string {
const canonicalPayload = JSON.stringify(
Object.fromEntries(
Object.entries(payload)
.filter(([, value]) => value !== undefined)
.sort(([left], [right]) => left.localeCompare(right)),
),
);
const payloadHash = createHash("sha256").update(canonicalPayload, "utf8").digest("hex");
return `vortex:rfq:v1:${payload.requestId}:${payloadHash}`;
}
96 changes: 93 additions & 3 deletions src/common/stellar-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,61 @@
*/
import { Keypair } from "@stellar/stellar-sdk";
import { UnauthorizedException } from "@nestjs/common";
import { createHash } from "node:crypto";
import { Keypair } from "@stellar/stellar-sdk";
import { UnauthorizedException } from "@nestjs/common";

export const INTENT_SIGNATURE_CLOCK_SKEW_SECONDS = 30;
export const MAX_INTENT_SIGNATURE_TTL_SECONDS = 900;

export interface IntentSignatureContext {
network: string;
nonce: string;
expiresAt: number;
}

function canonicalPayload(payload: Record<string, string | number | null>): string {
return JSON.stringify(
Object.fromEntries(Object.entries(payload).sort(([left], [right]) => left.localeCompare(right))),
);
}

function buildV2IntentMessage(
context: IntentSignatureContext,
action: "accept" | "fill" | "cancel",
intentId: string,
payload: Record<string, string | number | null>,
): string {
const payloadHash = createHash("sha256").update(canonicalPayload(payload), "utf8").digest("hex");
return `vortex:${context.network}:${action}:${intentId}:${context.nonce}:${context.expiresAt}:${payloadHash}`;
}

/**
* Verify that `signature` (base64) over `message` (utf-8) was produced by
* the private key corresponding to `publicKey` (Stellar G-address).
*
* Throws UnauthorizedException on any failure so callers can let it propagate
* straight to the HTTP layer.
*/
export function verifyStellarSignature(
publicKey: string,
message: string,
signature: string,
): void {
try {
const keypair = Keypair.fromPublicKey(publicKey);
const messageBytes = Buffer.from(message, "utf8");
const signatureBytes = Buffer.from(signature, "base64");
if (!keypair.verify(messageBytes, signatureBytes)) {
throw new UnauthorizedException("Invalid Stellar signature");
}
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException("Invalid Stellar signature");
}
}

/**
* Verify that `signature` (base64) over `message` (utf-8) was produced by
Expand Down Expand Up @@ -42,7 +97,11 @@ export function verifyStellarSignature(
/**
* Build the canonical message that a user must sign to cancel an intent.
*/
export function buildCancelMessage(intentId: string): string {
export function buildCancelMessage(intentId: string, context?: IntentSignatureContext, user?: string): string {
if (context) return buildV2IntentMessage(context, "cancel", intentId, { user: user ?? "" });

return `cancel:${intentId}`;
}
return `cancel:${intentId}`;
}

Expand All @@ -56,14 +115,32 @@ export function buildWsAuthMessage(solver: string, timestamp: number | string):
/**
* Build the canonical message that a solver must sign to accept an intent.
*/
export function buildAcceptMessage(intentId: string, solver: string): string {
export function buildAcceptMessage(intentId: string, solver: string, context?: IntentSignatureContext): string {
if (context) return buildV2IntentMessage(context, "accept", intentId, { solver });

return `accept:${intentId}:${solver}`;
}
return `accept:${intentId}:${solver}`;
}

/**
* Build the canonical message that a solver must sign to fill an intent.
*/
export function buildFillMessage(intentId: string, solver: string): string {
export function buildFillMessage(
intentId: string,
solver: string,
context?: IntentSignatureContext,
fill?: { fillAmount: string; txHash?: string },
): string {
if (context) {
return buildV2IntentMessage(context, "fill", intentId, {
solver,
fillAmount: fill?.fillAmount ?? "",
txHash: fill?.txHash ?? null,
});
}
return `fill:${intentId}:${solver}`;
}
return `fill:${intentId}:${solver}`;
}

Expand Down Expand Up @@ -114,3 +191,16 @@ export function buildDisputeReviewMessage(disputeId: string): string {
export function buildDisputeDecisionMessage(disputeId: string, resolution: string, reason: string): string {
return `dispute-decision:${disputeId}:${resolution}:${reason}`;
}

/**
* Build the canonical message that a solver must sign to update their mutable
* profile fields (name / supportedChains / supportedTokens / avgFillTime).
*
* Signing over just the address is sufficient here: it proves control of the
* account whose profile is being edited, and the request body is already
* constrained by the DTO whitelist so no immutable field can ride along.
*/
export function buildUpdateSolverMessage(address: string): string {
return `update-solver:${address}`;
}

Loading
Loading