diff --git a/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index dc70693..005ab64 100644 --- a/docs/runbooks/on-call.md +++ b/docs/runbooks/on-call.md @@ -39,8 +39,8 @@ read endpoints but does **not** take down the intent relay or WebSocket feed. | `GET /health` | `200 { status: "ok" }` | | `GET /api/v1/chain/health` | `200` with Soroban `status: "healthy"` | | Sweeper log (every 30 s) | Debug line: `sweep complete: expired=N duration=Xms` | -| `MetricsRegistry.sweeper.sweepDurationMs` p99 | < 50 ms under normal load | -| `MetricsRegistry.sweeper.expiredTotal` | Monotonically increasing; spikes expected near intent `deadline` clusters | +| `vortex_sweeper_sweep_duration_ms` p99 | < 50 ms under normal load | +| `vortex_sweeper_expired_total` | Monotonically increasing; spikes expected near intent `deadline` clusters | | WS subscriber count | Stable or slowly growing; sudden drops indicate client-side churn | | Node.js heap | Steady-state < 200 MB; no sustained upward trend between GC cycles | @@ -142,8 +142,11 @@ A sweep that has been delayed or killed will simply be absent. 2. Compares each intent's `deadline` (Unix timestamp) against `Date.now()`. 3. Calls `IntentsService.update()` and `IntentsGateway.broadcast()` for each expired intent. -4. Records `sweepDurationMs` and increments `expiredTotal` in - `MetricsRegistry.sweeper`. +4. Records `vortex_sweeper_sweep_duration_ms` and increments + `vortex_sweeper_expired_total` via `MetricsService.recordSweep()` (Prometheus, + exposed on `GET /metrics`). The retired `MetricsRegistry` from + `src/common/metrics.ts` has been removed (issue #259) — use the + Prometheus metric names above for alerting and dashboards. Because the store is in-memory and the loop is synchronous, the sweep should complete in **single-digit milliseconds** for < 10 000 open intents. @@ -182,8 +185,9 @@ or restart the service (the sweeper fires on the next 30-second tick after 3. **Check metrics** (if a metrics endpoint is wired up): ```bash curl -s http://localhost:4000/metrics | grep sweeper - # sweeper_sweep_duration_ms_count - # sweeper_expired_total + # vortex_sweeper_sweep_duration_ms_count + # vortex_sweeper_sweep_duration_ms_sum + # vortex_sweeper_expired_total ``` 4. **Inspect process health**: diff --git a/docs/runbooks/onchain-cutover.md b/docs/runbooks/onchain-cutover.md index 61b193a..3c199ee 100644 --- a/docs/runbooks/onchain-cutover.md +++ b/docs/runbooks/onchain-cutover.md @@ -19,7 +19,7 @@ should be reviewed/updated as each lands: | On-chain intent registration (issue #22) | Replaces in-memory `create()` with a real Soroban tx | Open | | Solver-registry wiring (issue #23) | `accept()` calls the solver-registry contract | Open | | On-chain fill settlement (issue #24) | `fill()` submits + confirms a settlement tx | Open | -| Dry-run mode (issue #35) | Config flag to simulate on-chain writes without submitting | Open | +| Dry-run mode (issue #35) | Config flag to simulate on-chain writes without submitting | **Done** (issue #260) | | Intent audit trail (issue #62) | Append-only log of every state transition, independent of the state store | Open | Treat the checklist below as the gate for actually running this procedure: @@ -86,32 +86,45 @@ immediately before flipping traffic: ## Dry-run flag and the cutover -The dry-run flag (issue #35) is the primary safety mechanism this runbook -leans on. It's a config-level switch (default **on** outside production, -per that issue's requirements) that makes every on-chain-write code path -build and simulate a Soroban transaction, log what *would* be submitted, -and return without broadcasting it. +The dry-run flag (`ONCHAIN_DRY_RUN`, issue #260 / #35) is the primary safety +mechanism this runbook leans on. It's a config-level switch (default **true** +outside production, per that issue's requirements) that makes every on-chain-write +code path (`StellarTxService.invokeContract`, `SolverRegistryService.slashSolver`) +build and simulate a Soroban transaction, log what *would* be submitted, and +return without broadcasting it. + +**Runtime-toggleable limitation:** The flag is loaded from environment config at +process start. Changing it requires a process restart — there is no hot-reload +HTTP endpoint for this iteration. This is an intentional simplification: the +staged rollout procedure below is designed around restart windows (not hot flips), +and the cost of a restart in staging is negligible compared to the risk of a +silent live-mode activation. A live-toggle mechanism is a separate future concern. + +**Production requirement:** `ONCHAIN_DRY_RUN` must be explicitly set in any +`NODE_ENV=production` environment — the process refuses to start without it +(validated by `src/config/env.validation.ts`). This prevents a misconfigured +production deploy from silently defaulting to either mode. How it factors into cutover staging: 1. **Stage 1 — dry-run in target environment.** Deploy the on-chain code - paths with the dry-run flag forced on, traffic unchanged (reads/writes + paths with `ONCHAIN_DRY_RUN=true` forced on, traffic unchanged (reads/writes still served from the in-memory store). This validates that transaction construction, contract ID wiring, and the signing key all work, with zero funds-moving risk. This is pre-check #2 above. -2. **Stage 2 — shadow writes.** Flip dry-run off for a canary slice (or a +2. **Stage 2 — shadow writes.** Flip `ONCHAIN_DRY_RUN=false` for a canary slice (or a single non-critical path, e.g. solver-registry reads before slashing writes) while the in-memory store remains authoritative for reads. Watch for transaction failures, unexpected fees, or confirmation-latency surprises. 3. **Stage 3 — cutover.** Flip the in-memory store from authoritative to cache (or remove it, per how #22/#24 implement this) for the full - read/write path. Dry-run stays off. This is the point of no return for - this procedure — from here, rollback means the explicit procedure below, - not just re-flipping a flag. + read/write path. `ONCHAIN_DRY_RUN=false` stays set. This is the point of + no return for this procedure — from here, rollback means the explicit + procedure below, not just re-flipping a flag. -Keep the dry-run flag itself deployed (not ripped out) after cutover — it's -the fastest lever if a related on-chain code path needs to be redeployed or +Keep `ONCHAIN_DRY_RUN` deployed (not ripped out) after cutover — it's the +fastest lever if a related on-chain code path needs to be redeployed or patched later without another full staged rollout. ## Rollback plan diff --git a/src/common/metrics.ts b/src/common/metrics.ts deleted file mode 100644 index 890f4df..0000000 --- a/src/common/metrics.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Lightweight in-process metrics store. - * - * Provides a Counter (monotonically increasing) and a Histogram (duration - * observations bucketed in milliseconds) without pulling in a full Prometheus - * client library. Values are exposed via the static `MetricsRegistry` - * singleton so any service can read or reset them in tests. - */ - -export class Counter { - private value = 0; - - /** Increment by `amount` (defaults to 1). */ - inc(amount = 1): void { - this.value += amount; - } - - /** Return the current total. */ - get(): number { - return this.value; - } - - /** Reset to zero (useful in tests). */ - reset(): void { - this.value = 0; - } -} - -export class Histogram { - /** Upper-bound bucket edges in milliseconds. */ - static readonly DEFAULT_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000]; - - private readonly buckets: Map; - private sum = 0; - private count = 0; - - constructor(bucketEdges: number[] = Histogram.DEFAULT_BUCKETS) { - this.buckets = new Map(bucketEdges.sort((a, b) => a - b).map((b) => [b, 0])); - } - - /** Record one observation (milliseconds). */ - observe(ms: number): void { - this.sum += ms; - this.count += 1; - for (const edge of this.buckets.keys()) { - if (ms <= edge) { - this.buckets.set(edge, (this.buckets.get(edge) ?? 0) + 1); - } - } - } - - getCount(): number { - return this.count; - } - - getSum(): number { - return this.sum; - } - - /** Return a snapshot: { buckets, sum, count }. */ - snapshot(): { buckets: Record; sum: number; count: number } { - const buckets: Record = {}; - for (const [edge, cnt] of this.buckets) { - buckets[`le_${edge}`] = cnt; - } - return { buckets, sum: this.sum, count: this.count }; - } - - /** Reset all observations (useful in tests). */ - reset(): void { - this.sum = 0; - this.count = 0; - for (const edge of this.buckets.keys()) { - this.buckets.set(edge, 0); - } - } -} - -/** Singleton registry — import and use from any module. */ -export const MetricsRegistry = { - sweeper: { - /** Total number of intents expired across all sweeps. */ - expiredTotal: new Counter(), - /** Duration (ms) of each sweep() execution. */ - sweepDurationMs: new Histogram(), - }, -} as const; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index aa84559..fd980b4 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -49,6 +49,19 @@ export interface AppConfig { feePercentile: FeePercentile; }; onchainIntentsEnabled: boolean; + /** + * Dry-run flag for on-chain write paths (issue #260). + * + * When true every write path (invokeContract, slashSolver) simulates and + * logs but never broadcasts a transaction. Defaults to true outside + * production; must be explicitly set in production (validated by + * envValidationSchema — see src/config/env.validation.ts). + * + * Note: this flag takes effect on the next process restart; there is no + * hot-reload mechanism for this iteration. See + * docs/runbooks/onchain-cutover.md for the staged rollout procedure. + */ + onchainDryRun: boolean; corsOrigin: string; /** Maximum concurrent WebSocket connections (0 = unlimited). */ wsMaxConnections: number; @@ -69,6 +82,11 @@ export default (): AppConfig => ({ feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile, }, onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", + // Default to dry-run (true) outside production; in production the value must + // be explicitly set (validated by envValidationSchema). + onchainDryRun: process.env.ONCHAIN_DRY_RUN !== undefined + ? process.env.ONCHAIN_DRY_RUN === "true" + : process.env.NODE_ENV !== "production", corsOrigin: process.env.CORS_ORIGIN ?? "*", wsMaxConnections: parseInt(process.env.WS_MAX_CONNECTIONS ?? "1000", 10), }); diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index ba19b5f..d0bb3f4 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -50,8 +50,67 @@ describe("envValidationSchema — SOROBAN_SIGNING_KEY", () => { const { error, value } = envValidationSchema.validate({ NODE_ENV: "production", SOROBAN_SIGNING_KEY: VALID_KEY, + // ONCHAIN_DRY_RUN is required in production (issue #260) — include it here + // so this test stays focused on SOROBAN_SIGNING_KEY validation only. + ONCHAIN_DRY_RUN: true, }); expect(error).toBeUndefined(); expect(value.SOROBAN_SIGNING_KEY).toBe(VALID_KEY); }); }); + +describe("envValidationSchema — ONCHAIN_DRY_RUN (#260)", () => { + it("defaults to true outside production when unset", () => { + const { error, value } = envValidationSchema.validate(BASE_ENV); + expect(error).toBeUndefined(); + expect(value.ONCHAIN_DRY_RUN).toBe(true); + }); + + it("accepts true outside production", () => { + const { error, value } = envValidationSchema.validate({ + ...BASE_ENV, + ONCHAIN_DRY_RUN: true, + }); + expect(error).toBeUndefined(); + expect(value.ONCHAIN_DRY_RUN).toBe(true); + }); + + it("accepts false outside production (explicit opt-out)", () => { + const { error, value } = envValidationSchema.validate({ + ...BASE_ENV, + ONCHAIN_DRY_RUN: false, + }); + expect(error).toBeUndefined(); + expect(value.ONCHAIN_DRY_RUN).toBe(false); + }); + + it("is required in production — missing value fails validation", () => { + const { error } = envValidationSchema.validate({ + NODE_ENV: "production", + SOROBAN_SIGNING_KEY: VALID_KEY, + // ONCHAIN_DRY_RUN deliberately omitted + }); + expect(error).toBeDefined(); + expect(error?.message).toContain("ONCHAIN_DRY_RUN"); + }); + + it("accepts true in production (keep simulate-only after cutover)", () => { + const { error, value } = envValidationSchema.validate({ + NODE_ENV: "production", + SOROBAN_SIGNING_KEY: VALID_KEY, + ONCHAIN_DRY_RUN: true, + }); + expect(error).toBeUndefined(); + expect(value.ONCHAIN_DRY_RUN).toBe(true); + }); + + it("accepts false in production (live on-chain writes enabled)", () => { + const { error, value } = envValidationSchema.validate({ + NODE_ENV: "production", + SOROBAN_SIGNING_KEY: VALID_KEY, + ONCHAIN_DRY_RUN: false, + }); + expect(error).toBeUndefined(); + expect(value.ONCHAIN_DRY_RUN).toBe(false); + }); +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 8a2dc55..3603fe4 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -60,4 +60,36 @@ export const envValidationSchema = Joi.object({ // as a documentation hint and config validation guard only. "debug", ), + + // ── On-chain write safety flag (issue #35 / issue #260) ────────────────── + // When true, every on-chain-write code path (invokeContract, slashSolver) + // builds and simulates the transaction, logs what it *would* submit, and + // returns without broadcasting — safe by construction. + // + // Default behaviour: + // - Outside production: defaults to true (simulate-only, fail closed + // toward safety — no real funds moved without an explicit opt-out). + // - In production: *required* to be explicitly set. Omitting it in a + // production deploy fails validation so the operator must consciously + // decide between dry-run and live mode before traffic reaches + // on-chain write paths. This matches the fail-closed pattern used + // for SOROBAN_SIGNING_KEY. + // + // Limitations: the flag is config-driven and takes effect on the next + // process start; there is no HTTP endpoint to flip it at runtime without + // a restart. This limitation is documented in onchain-cutover.md and is + // intentional for this iteration — a hot-reload mechanism is a separate + // concern. Set ONCHAIN_DRY_RUN=false only after completing the dry-run + // soak described in docs/runbooks/onchain-cutover.md. + ONCHAIN_DRY_RUN: Joi.boolean() + .when("NODE_ENV", { + is: "production", + then: Joi.required().messages({ + "any.required": + "ONCHAIN_DRY_RUN must be explicitly set in production. " + + "Set to true to remain in simulate-only mode, or false to enable live on-chain writes. " + + "See docs/runbooks/onchain-cutover.md for the staged rollout procedure.", + }), + otherwise: Joi.boolean().default(true), + }), }); diff --git a/src/intents/intents-sweeper.service.spec.ts b/src/intents/intents-sweeper.service.spec.ts index 6017b7b..447e87a 100644 --- a/src/intents/intents-sweeper.service.spec.ts +++ b/src/intents/intents-sweeper.service.spec.ts @@ -1,17 +1,24 @@ import { Test, TestingModule } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; +import { Keypair } from "@stellar/stellar-sdk"; import { IntentsSweeperService } from "./intents-sweeper.service"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; import { SolverRegistryService } from "../soroban/solver-registry.service"; +import { MetricsService } from "../metrics/metrics.service"; import { InMemorySolversRepository } from "../solvers/in-memory-solvers.repository"; +import { SOLVERS_REPOSITORY } from "../solvers/solvers.repository"; import { InMemoryIntentsRepository, INTENTS_REPOSITORY } from "./intents.repository"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; import { AppConfig } from "../config/configuration"; -function fakeIntentsService(): IntentsService { +/** Use a stable test address (does not need to be a real funded key). */ +const ALPHA_KEYPAIR = Keypair.random(); +const ALPHA_ADDR = ALPHA_KEYPAIR.publicKey(); + +function buildIntentsService(): IntentsService { const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; @@ -22,7 +29,10 @@ function fakeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - return new IntentsService(configService, stellarTxService, prismaService); + const repo = new InMemoryIntentsRepository(); + // Clear seed data so tests start with a clean slate + (repo as unknown as { store: Map }).store.clear(); + return new IntentsService(repo, configService, stellarTxService, prismaService); } async function buildSolversService(): Promise { @@ -40,11 +50,12 @@ describe("IntentsSweeperService", () => { let gateway: IntentsGateway; let solversService: SolversService; let solverRegistryService: jest.Mocked; + let metricsService: jest.Mocked>; let sweeper: IntentsSweeperService; beforeEach(async () => { - intentsService = await buildIntentsService(); - gateway = { broadcast: jest.fn() } as unknown as IntentsGateway; + intentsService = buildIntentsService(); + gateway = { broadcast: jest.fn().mockResolvedValue(undefined) } as unknown as IntentsGateway; solversService = await buildSolversService(); solverRegistryService = { slashSolver: jest.fn().mockResolvedValue({ @@ -53,12 +64,14 @@ describe("IntentsSweeperService", () => { detail: "not configured — no-op", }), } as unknown as jest.Mocked; + metricsService = { recordSweep: jest.fn() } as unknown as jest.Mocked>; sweeper = new IntentsSweeperService( intentsService, gateway, solversService, solverRegistryService, + metricsService as unknown as MetricsService, ); }); @@ -117,6 +130,18 @@ describe("IntentsSweeperService", () => { it("bumps the solver's fillsFailed counter on a slash", async () => { const past = Math.floor(Date.now() / 1000) - 10; + + // Register the solver so recordFailedFill has a record to update + await solversService.register({ + address: ALPHA_ADDR, + name: "Alpha Test Solver", + bondAmount: "1000000", + isActive: true, + supportedChains: ["ethereum"], + supportedTokens: ["USDC"], + avgFillTime: 30, + }); + const before = (await solversService.get(ALPHA_ADDR))?.fillsFailed ?? 0; const intentId = await makeAcceptedIntent(past, ALPHA_ADDR); @@ -153,4 +178,43 @@ describe("IntentsSweeperService", () => { expect((await intentsService.get(intent.intentId))?.state).toBe("slashed"); expect(solverRegistryService.slashSolver).not.toHaveBeenCalled(); }); + + // ── #259: MetricsService integration ──────────────────────────────────── + + it("records sweep metrics via MetricsService on every sweep cycle", async () => { + await sweeper.sweep(); + expect(metricsService.recordSweep).toHaveBeenCalledTimes(1); + const [expiredCount, durationMs] = (metricsService.recordSweep as jest.Mock).mock.calls[0] as [number, number]; + expect(typeof expiredCount).toBe("number"); + expect(typeof durationMs).toBe("number"); + expect(durationMs).toBeGreaterThanOrEqual(0); + }); + + it("records correct expired count in MetricsService", async () => { + const past = Math.floor(Date.now() / 1000) - 10; + // Create 2 expired intents + await intentsService.create({ + user: "GTEST...0001", + srcChain: "stellar", + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: past, + }); + await intentsService.create({ + user: "GTEST...0002", + srcChain: "stellar", + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: past, + }); + + await sweeper.sweep(); + + const [expiredCount] = (metricsService.recordSweep as jest.Mock).mock.calls[0] as [number, number]; + expect(expiredCount).toBe(2); + }); }); diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index f2ec0cd..5d125bf 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -3,6 +3,7 @@ import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; import { SolverRegistryService } from "../soroban/solver-registry.service"; +import { MetricsService } from "../metrics/metrics.service"; const SWEEP_INTERVAL_MS = 30_000; @@ -16,6 +17,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { private readonly intentsGateway: IntentsGateway, private readonly solversService: SolversService, private readonly solverRegistryService: SolverRegistryService, + private readonly metricsService: MetricsService, ) {} onModuleInit() { @@ -47,12 +49,16 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { { deadline: intent.deadline, sweepedAt: now }, ); expiredCount++; - this.intentsGateway.broadcast({ type: "intent_expired", intentId: intent.intentId }); + await this.intentsGateway.broadcast({ type: "intent_expired", intentId: intent.intentId }); } } const durationMs = Date.now() - startMs; + // Record sweep metrics into the Prometheus-backed MetricsService (issue #259). + // This replaces the retired MetricsRegistry from src/common/metrics.ts. + this.metricsService.recordSweep(expiredCount, durationMs); + this.logger.debug(`sweep complete: expired=${expiredCount} duration=${durationMs}ms`); if (expiredCount > 0) { @@ -80,7 +86,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { slashedAt: now, slashReason: reason, }); - this.intentsGateway.broadcast({ type: "intent_slashed", intentId, solver, reason }); + await this.intentsGateway.broadcast({ type: "intent_slashed", intentId, solver, reason }); if (!solver) { // Shouldn't happen in practice — an "accepted" intent always has a diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index 238d648..17594dd 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -1,6 +1,6 @@ import { Test } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; -import { IntentsGateway } from "./intents.gateway"; +import { IntentsGateway, EventRingBuffer } from "./intents.gateway"; import { IntentsService } from "./intents.service"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; @@ -27,13 +27,19 @@ function makeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - return new IntentsService(configService, {} as StellarTxService, prismaService); + const repo = new InMemoryIntentsRepository(); + return new IntentsService( + repo, + configService, + {} as StellarTxService, + prismaService, + ); } function createMockClient() { const listeners: Record void> = {}; return { - readyState: 1, + readyState: 1, // WebSocket.OPEN send: jest.fn(), ping: jest.fn(), terminate: jest.fn(), @@ -41,10 +47,71 @@ function createMockClient() { on: jest.fn((event: string, cb: (...args: unknown[]) => void) => { listeners[event] = cb; }), + off: jest.fn(), _listeners: listeners, + // Helper: simulate an incoming message from the client + _emit: function (event: string, ...args: unknown[]) { + if (this._listeners[event]) this._listeners[event](...args); + }, }; } +// ── EventRingBuffer unit tests ───────────────────────────────────────────── + +describe("EventRingBuffer", () => { + it("returns -1 for oldestSeq when empty", () => { + const buf = new EventRingBuffer(5); + expect(buf.oldestSeq()).toBe(-1); + }); + + it("returns 0 for latestSeq when empty", () => { + const buf = new EventRingBuffer(5); + expect(buf.latestSeq()).toBe(0); + }); + + it("tracks size", () => { + const buf = new EventRingBuffer(5); + buf.push({ seq: 1, type: "a" }); + buf.push({ seq: 2, type: "b" }); + expect(buf.size()).toBe(2); + }); + + it("evicts oldest when at capacity", () => { + const buf = new EventRingBuffer(3); + buf.push({ seq: 1, type: "a" }); + buf.push({ seq: 2, type: "b" }); + buf.push({ seq: 3, type: "c" }); + buf.push({ seq: 4, type: "d" }); // evicts seq=1 + expect(buf.oldestSeq()).toBe(2); + expect(buf.size()).toBe(3); + }); + + it("since returns only events after the given seq", () => { + const buf = new EventRingBuffer(10); + for (let i = 1; i <= 5; i++) buf.push({ seq: i, type: "e" }); + const result = buf.since(3); + expect(result.map((e) => e.seq)).toEqual([4, 5]); + }); + + it("since returns empty array when fromSeq >= latestSeq", () => { + const buf = new EventRingBuffer(10); + buf.push({ seq: 1, type: "e" }); + expect(buf.since(1)).toEqual([]); + expect(buf.since(99)).toEqual([]); + }); + + it("since returns all events when fromSeq < oldestSeq", () => { + const buf = new EventRingBuffer(3); + buf.push({ seq: 5, type: "e" }); + buf.push({ seq: 6, type: "e" }); + // fromSeq=1 is older than oldest (5), since() returns events with seq > 1 — all + const result = buf.since(1); + expect(result.map((e) => e.seq)).toEqual([5, 6]); + }); +}); + +// ── IntentsGateway heartbeat tests ──────────────────────────────────────── + describe("IntentsGateway heartbeat", () => { let gateway: IntentsGateway; let intentsService: IntentsService; @@ -52,7 +119,7 @@ describe("IntentsGateway heartbeat", () => { beforeEach(async () => { jest.useFakeTimers(); jest.clearAllMocks(); - intentsService = await makeIntentsService(); + intentsService = makeIntentsService(); gateway = new IntentsGateway(intentsService); }); @@ -116,20 +183,31 @@ describe("IntentsGateway heartbeat", () => { expect(true).toBe(true); }); - it("broadcasts to all alive subscribers", () => { + it("broadcasts to all alive subscribers (unfiltered)", async () => { const c1 = createMockClient(); const c2 = createMockClient(); gateway.handleConnection(c1 as unknown as import("ws").WebSocket); gateway.handleConnection(c2 as unknown as import("ws").WebSocket); - gateway.broadcast({ type: "test_event", data: 123 }); + // Wait for the async snapshot send to complete before clearing mocks + await Promise.resolve(); + + c1.send.mockClear(); + c2.send.mockClear(); - const expected = JSON.stringify({ type: "test_event", data: 123 }); - expect(c1.send).toHaveBeenCalledWith(expected); - expect(c2.send).toHaveBeenCalledWith(expected); + await gateway.broadcast({ type: "test_event", data: 123 }); + + expect(c1.send).toHaveBeenCalledTimes(1); + expect(c2.send).toHaveBeenCalledTimes(1); + // Both payloads should contain the event type + const payload1 = JSON.parse(c1.send.mock.calls[0][0] as string); + expect(payload1.type).toBe("test_event"); + expect(typeof payload1.seq).toBe("number"); }); }); +// ── IntentsGateway logging tests ────────────────────────────────────────── + describe("IntentsGateway logging", () => { let gateway: IntentsGateway; let intentsService: IntentsService; @@ -137,7 +215,7 @@ describe("IntentsGateway logging", () => { beforeEach(async () => { jest.useFakeTimers(); jest.clearAllMocks(); - intentsService = await makeIntentsService(); + intentsService = makeIntentsService(); gateway = new IntentsGateway(intentsService); }); @@ -165,14 +243,14 @@ describe("IntentsGateway logging", () => { expect(logger.info).toHaveBeenCalledWith("ws client disconnected (subscribers=0)"); }); - it("logs broadcast event type without payload", () => { + it("logs broadcast event type without payload", async () => { const client = createMockClient(); gateway.handleConnection(client as unknown as import("ws").WebSocket); - gateway.broadcast({ type: "intent_created", intent: { id: "123", secret: "data" } }); + await gateway.broadcast({ type: "intent_created", intent: { id: "123", secret: "data" } }); expect(logger.debug).toHaveBeenCalledWith( - "ws broadcast type=intent_created subscribers=1", + expect.stringMatching(/ws broadcast type=intent_created/), ); }); @@ -187,3 +265,289 @@ describe("IntentsGateway logging", () => { ); }); }); + +// ── #257: Chain subscription filtering ──────────────────────────────────── + +describe("IntentsGateway — chain subscription filtering (#257)", () => { + let gateway: IntentsGateway; + let intentsService: IntentsService; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + intentsService = makeIntentsService(); + gateway = new IntentsGateway(intentsService); + }); + + afterEach(() => { + gateway.onModuleDestroy(); + jest.useRealTimers(); + }); + + it("responds with subscribed message when client sends valid subscribe", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Simulate incoming subscribe message + client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar", "ethereum"] }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const subscribed = calls.find((m) => m.type === "subscribed"); + expect(subscribed).toBeDefined(); + expect(subscribed.filter.chains).toEqual(expect.arrayContaining(["stellar", "ethereum"])); + }); + + it("strips invalid chain values from subscribe message", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + client._emit("message", Buffer.from(JSON.stringify({ + type: "subscribe", + chains: ["stellar", "invalid_chain", "STELLAR", 123], + }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const subscribed = calls.find((m) => m.type === "subscribed"); + expect(subscribed).toBeDefined(); + // Only "stellar" survives validation + expect(subscribed.filter.chains).toEqual(["stellar"]); + }); + + it("ignores subscribe message with missing chains field", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Should not crash and should not send subscribed + client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe" }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const subscribed = calls.find((m) => m.type === "subscribed"); + expect(subscribed).toBeUndefined(); + }); + + it("ignores malformed JSON without crashing", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Should not throw + expect(() => { + client._emit("message", Buffer.from("not valid json{{{")); + }).not.toThrow(); + }); + + it("delivers intent_created only to subscribed chain clients", async () => { + const stellarClient = createMockClient(); + const ethClient = createMockClient(); + const allClient = createMockClient(); // no subscribe = receives all + + gateway.handleConnection(stellarClient as unknown as import("ws").WebSocket); + gateway.handleConnection(ethClient as unknown as import("ws").WebSocket); + gateway.handleConnection(allClient as unknown as import("ws").WebSocket); + + // Subscribe stellar client to stellar only + stellarClient._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar"] }))); + // Subscribe eth client to ethereum only + ethClient._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["ethereum"] }))); + + stellarClient.send.mockClear(); + ethClient.send.mockClear(); + allClient.send.mockClear(); + + // Broadcast a stellar intent_created + await gateway.broadcast({ + type: "intent_created", + intent: { intentId: "abc", srcChain: "stellar", state: "open" }, + }); + + // stellarClient and allClient should receive it + const stellarCalls = stellarClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const ethCalls = ethClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const allCalls = allClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + + expect(stellarCalls.some((m) => m.type === "intent_created")).toBe(true); + expect(ethCalls.some((m) => m.type === "intent_created")).toBe(false); // filtered out + expect(allCalls.some((m) => m.type === "intent_created")).toBe(true); + }); + + it("delivers intent to all subscribers when chain is not resolvable", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar"] }))); + client.send.mockClear(); + + // Unknown type with no chain + await gateway.broadcast({ type: "system_announcement", message: "maintenance" }); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + expect(calls.some((m) => m.type === "system_announcement")).toBe(true); + }); + + it("unfiltered client (no subscribe) receives all events", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + await gateway.broadcast({ + type: "intent_created", + intent: { intentId: "xyz", srcChain: "ethereum", state: "open" }, + }); + await gateway.broadcast({ + type: "intent_created", + intent: { intentId: "abc", srcChain: "stellar", state: "open" }, + }); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const created = calls.filter((m) => m.type === "intent_created"); + expect(created).toHaveLength(2); + }); + + it("assigns increasing seq numbers to broadcast events", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + await gateway.broadcast({ type: "e1" }); + await gateway.broadcast({ type: "e2" }); + await gateway.broadcast({ type: "e3" }); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const seqs = calls.map((m: { seq: number }) => m.seq); + // seq values should be strictly increasing + for (let i = 1; i < seqs.length; i++) { + expect(seqs[i]).toBeGreaterThan(seqs[i - 1]); + } + }); +}); + +// ── #258: Event replay ──────────────────────────────────────────────────── + +describe("IntentsGateway — event replay (#258)", () => { + let gateway: IntentsGateway; + let intentsService: IntentsService; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + intentsService = makeIntentsService(); + gateway = new IntentsGateway(intentsService); + }); + + afterEach(() => { + gateway.onModuleDestroy(); + jest.useRealTimers(); + }); + + it("returns replay_start, replayed events, and replay_end for valid fromSeq", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + + // Broadcast 3 events so they land in the ring buffer with seq 1, 2, 3 + await gateway.broadcast({ type: "e1" }); + await gateway.broadcast({ type: "e2" }); + await gateway.broadcast({ type: "e3" }); + + client.send.mockClear(); + + // Request replay from seq=1 (expect events with seq > 1 → seq 2 and 3) + client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 1 }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const startMsg = calls.find((m) => m.type === "replay_start"); + const endMsg = calls.find((m) => m.type === "replay_end"); + const events = calls.filter((m) => m.type === "e2" || m.type === "e3"); + + expect(startMsg).toBeDefined(); + expect(startMsg.fromSeq).toBe(1); + expect(startMsg.count).toBe(2); + expect(events).toHaveLength(2); + expect(endMsg).toBeDefined(); + expect(endMsg.count).toBe(2); + }); + + it("returns replay_too_old when fromSeq has been evicted from the buffer", async () => { + // Use a tiny ring buffer (capacity 2) to force eviction + const tinyGateway = new IntentsGateway(intentsService); + // @ts-expect-error – accessing private field for test setup + tinyGateway.ringBuffer["capacity"] = 2; + + const client = createMockClient(); + tinyGateway.handleConnection(client as unknown as import("ws").WebSocket); + + // Broadcast enough to evict seq=1 + await tinyGateway.broadcast({ type: "e1" }); // seq=1 + await tinyGateway.broadcast({ type: "e2" }); // seq=2 + await tinyGateway.broadcast({ type: "e3" }); // seq=3 — evicts seq=1 + + client.send.mockClear(); + + // seq=1 is now gone; oldest is seq=2. fromSeq=0 < oldest-1=1 → too_old + client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 0 }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const tooOld = calls.find((m) => m.type === "replay_too_old"); + expect(tooOld).toBeDefined(); + expect(tooOld.fromSeq).toBe(0); + expect(typeof tooOld.oldestAvailableSeq).toBe("number"); + + tinyGateway.onModuleDestroy(); + }); + + it("returns replay with 0 events when fromSeq equals latest buffered seq", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + + await gateway.broadcast({ type: "e1" }); // seq=1 + const lastSeq = 1; + + client.send.mockClear(); + + client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: lastSeq }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const startMsg = calls.find((m) => m.type === "replay_start"); + expect(startMsg).toBeDefined(); + expect(startMsg.count).toBe(0); + }); + + it("ignores replay with missing fromSeq", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + client._emit("message", Buffer.from(JSON.stringify({ type: "replay" }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + expect(calls.find((m) => m.type === "replay_start")).toBeUndefined(); + expect(calls.find((m) => m.type === "replay_too_old")).toBeUndefined(); + }); + + it("handles replay on an empty buffer (returns replay_start with count 0)", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Buffer is empty — oldestSeq() = -1, so the not-too-old path is taken + client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 0 }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const startMsg = calls.find((m) => m.type === "replay_start"); + const endMsg = calls.find((m) => m.type === "replay_end"); + expect(startMsg).toBeDefined(); + expect(startMsg.count).toBe(0); + expect(endMsg).toBeDefined(); + }); + + it("pushes broadcast events into the ring buffer before sending", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + + await gateway.broadcast({ type: "test_buffered" }); + + // @ts-expect-error – accessing private for assertion + expect(gateway.ringBuffer.size()).toBe(1); + }); +}); diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index a03c0be..844ad90 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -2,11 +2,21 @@ import { OnModuleDestroy } from "@nestjs/common"; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets"; import { WebSocket } from "ws"; import { IntentsService } from "./intents.service"; +import { SUPPORTED_CHAINS, SupportedChain } from "./intents.types"; import { logger } from "../common/logger"; const HEARTBEAT_INTERVAL_MS = 30_000; -/** How many sequenced events to keep in the replay buffer. */ +/** + * How many sequenced events to keep in the replay buffer. + * + * At typical broadcast volume (a few dozen events/minute in production), + * 500 events covers many minutes of missed events — more than enough to + * bridge a transient network blip or container restart without forcing a + * full snapshot re-fetch. Increasing this beyond ~1 000 starts to add + * non-trivial heap pressure for large event payloads; the current bound + * is a deliberate memory vs. reconnect-gap tradeoff. + */ const REPLAY_BUFFER_SIZE = 500; export interface SequencedEvent { @@ -15,6 +25,14 @@ export interface SequencedEvent { [key: string]: unknown; } +/** + * Per-subscriber chain filter. `null` means "no filter set" — the client + * receives the full unfiltered feed (backward-compatible default). + */ +interface SubscriberFilter { + chains: Set | null; +} + /** * Fixed-size ring buffer that retains the last `capacity` events so * reconnecting clients can request a replay from a known sequence number. @@ -72,19 +90,27 @@ export class EventRingBuffer { export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy { - private readonly subscribers = new Set(); + /** + * Map from WebSocket client to its per-connection subscription filter. + * A filter with `chains: null` means the client receives all events + * (the default when no `subscribe` message has been sent). + */ + private readonly subscribers = new Map(); private readonly alive = new WeakMap(); // eslint-disable-next-line @typescript-eslint/no-explicit-any private heartbeatTimer: any; private nextSeq = 1; + /** Ring buffer storing the last REPLAY_BUFFER_SIZE broadcast events. */ + private readonly ringBuffer = new EventRingBuffer(REPLAY_BUFFER_SIZE); + constructor(private readonly intentsService: IntentsService) { this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS); logger.info("ws heartbeat started"); } handleConnection(client: WebSocket) { - this.subscribers.add(client); + this.subscribers.set(client, { chains: null }); this.alive.set(client, true); client.on("pong", () => { @@ -98,6 +124,11 @@ export class IntentsGateway ); }); + // Handle incoming messages: subscribe and replay requests. + client.on("message", (raw) => { + this.handleMessage(client, raw); + }); + const currentSeq = this.nextSeq - 1; client.send( @@ -126,18 +157,244 @@ export class IntentsGateway logger.info(`ws client disconnected (subscribers=${this.subscribers.size})`); } - broadcast(event: { type: string; [key: string]: unknown }) { - logger.debug(`ws broadcast type=${event.type} subscribers=${this.subscribers.size}`); - const payload = JSON.stringify(event); - for (const client of this.subscribers) { + /** + * Handle a single incoming WebSocket message from a client. + * + * Supported message types: + * - `{ type: "subscribe", chains: string[] }` — set a per-connection chain + * filter and respond with `{ type: "subscribed", filter: { chains } }`. + * - `{ type: "replay", fromSeq: number }` — replay buffered events since + * `fromSeq`, wrapped in replay_start / replay_end frames. + * + * Unknown types and malformed messages are silently ignored; they never + * crash the connection. + */ + private handleMessage(client: WebSocket, raw: import("ws").RawData): void { + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + // Malformed JSON — ignore silently. + return; + } + + if (typeof parsed !== "object" || parsed === null) return; + + const msg = parsed as Record; + + switch (msg.type) { + case "subscribe": + this.handleSubscribe(client, msg); + break; + case "replay": + this.handleReplay(client, msg); + break; + default: + // Unknown message type — ignore, do not crash the connection. + break; + } + } + + /** + * Process a `{ type: "subscribe", chains: string[] }` message. + * + * Validates each chain value against `SUPPORTED_CHAINS` and stores only + * the valid subset. A subscribe message with no valid chains is treated as + * "subscribe to nothing" (the client will receive only chainless events). + * An entirely missing or non-array `chains` field is rejected silently + * without updating the existing filter. + */ + private handleSubscribe(client: WebSocket, msg: Record): void { + if (!Array.isArray(msg.chains)) { + logger.debug("ws subscribe ignored: chains field missing or not an array"); + return; + } + + const validChains = (msg.chains as unknown[]).filter( + (c): c is SupportedChain => + typeof c === "string" && (SUPPORTED_CHAINS as readonly string[]).includes(c), + ); + + this.subscribers.set(client, { chains: new Set(validChains) }); + + logger.debug(`ws client subscribed to chains: ${validChains.join(", ") || "(none)"}`); + + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: "subscribed", + filter: { chains: validChains }, + }), + ); + } + } + + /** + * Process a `{ type: "replay", fromSeq: number }` message. + * + * If `fromSeq` falls within the buffer (i.e. `fromSeq >= oldestSeq - 1`), + * the missed events are streamed back wrapped in `replay_start` / + * `replay_end` frames. Otherwise, `replay_too_old` is returned so the + * client knows it must fall back to a fresh snapshot via REST. + * + * The boundary check: `oldestSeq() - 1` because `since(fromSeq)` returns + * events with `seq > fromSeq`, so a client asking for `fromSeq = oldest - 1` + * will receive the oldest event — the smallest valid ask. + */ + private handleReplay(client: WebSocket, msg: Record): void { + const fromSeq = typeof msg.fromSeq === "number" ? msg.fromSeq : null; + if (fromSeq === null || !Number.isInteger(fromSeq) || fromSeq < 0) { + logger.debug("ws replay ignored: fromSeq missing or invalid"); + return; + } + + if (client.readyState !== WebSocket.OPEN) return; + + const oldest = this.ringBuffer.oldestSeq(); + + // oldest === -1 means the buffer is empty — nothing to replay. + // The check `fromSeq < oldest - 1` catches the case where the requested + // seq has already been evicted from the ring buffer. + if (oldest !== -1 && fromSeq < oldest - 1) { + client.send( + JSON.stringify({ + type: "replay_too_old", + fromSeq, + oldestAvailableSeq: oldest, + }), + ); + logger.debug(`ws replay_too_old: fromSeq=${fromSeq} oldestAvailable=${oldest}`); + return; + } + + const events = this.ringBuffer.since(fromSeq); + + client.send( + JSON.stringify({ + type: "replay_start", + fromSeq, + count: events.length, + }), + ); + + for (const event of events) { + if (client.readyState !== WebSocket.OPEN) break; + client.send(JSON.stringify(event)); + } + + if (client.readyState === WebSocket.OPEN) { + client.send( + JSON.stringify({ + type: "replay_end", + count: events.length, + }), + ); + } + + logger.debug(`ws replay complete: fromSeq=${fromSeq} count=${events.length}`); + } + + /** + * Resolve the source chain for an event payload. + * + * - `intent_created`: the intent object is inlined in the event, so + * `srcChain` can be read directly without a service lookup. + * - State-transition events (`intent_accepted`, `intent_filled`, + * `intent_cancelled`, `intent_expired`, `intent_slashed`): only the + * `intentId` is available, so the intent must be looked up to get its + * `srcChain`. This is async and returns `null` on any lookup failure. + * - Everything else: returns `null` (event is delivered to all subscribers). + */ + private async getEventChain( + event: { type: string; [key: string]: unknown }, + ): Promise { + if (event.type === "intent_created") { + const intent = event.intent as { srcChain?: string } | undefined; + const chain = intent?.srcChain; + if (chain && (SUPPORTED_CHAINS as readonly string[]).includes(chain)) { + return chain as SupportedChain; + } + return null; + } + + const lookupTypes = new Set([ + "intent_accepted", + "intent_filled", + "intent_cancelled", + "intent_expired", + "intent_slashed", + ]); + + if (lookupTypes.has(event.type)) { + const intentId = typeof event.intentId === "string" ? event.intentId : null; + if (!intentId) return null; + + try { + const intent = await this.intentsService.get(intentId); + if (intent && (SUPPORTED_CHAINS as readonly string[]).includes(intent.srcChain)) { + return intent.srcChain as SupportedChain; + } + } catch { + // Lookup failure is non-fatal — deliver to all subscribers. + } + return null; + } + + return null; + } + + /** + * Assign a monotonically increasing sequence number, push the event into + * the ring buffer, then deliver it to every subscriber whose chain filter + * matches. + * + * Filter semantics: + * - A subscriber with `chains === null` (never sent a subscribe message) + * receives all events — backward-compatible with read-only consumers. + * - A subscriber with a non-null chain set receives the event only if the + * event's chain is in their set, or if the chain could not be resolved + * (null) — unchained events are always delivered to everyone. + */ + async broadcast(event: { type: string; [key: string]: unknown }): Promise { + const seq = this.nextSeq++; + const sequencedEvent: SequencedEvent = { ...event, seq }; + + // Push into replay buffer before sending so a racing replay request + // issued immediately after this broadcast still finds the event. + this.ringBuffer.push(sequencedEvent); + + logger.debug(`ws broadcast type=${event.type} seq=${seq} subscribers=${this.subscribers.size}`); + + // Resolve the chain once — shared across all subscriber checks. + const eventChain = await this.getEventChain(event); + + const payload = JSON.stringify(sequencedEvent); + + for (const [client, filter] of this.subscribers) { if (client.readyState !== WebSocket.OPEN) continue; - client.send(payload); + + // No filter set → full unfiltered feed (backward-compatible default). + if (filter.chains === null) { + client.send(payload); + continue; + } + + // Chain couldn't be resolved → deliver to everyone (safe default). + if (eventChain === null) { + client.send(payload); + continue; + } + + // Only send if the event's chain is in this subscriber's filter. + if (filter.chains.has(eventChain)) { + client.send(payload); + } } } getAliveCount(): number { let count = 0; - for (const client of this.subscribers) { + for (const client of this.subscribers.keys()) { if (this.alive.get(client) === true) count++; } return count; @@ -153,7 +410,7 @@ export class IntentsGateway } private heartbeat() { - for (const client of this.subscribers) { + for (const [client] of this.subscribers) { if (this.alive.get(client) === false) { client.terminate(); this.subscribers.delete(client); @@ -172,7 +429,7 @@ export class IntentsGateway onModuleDestroy() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); - for (const client of this.subscribers) { + for (const [client] of this.subscribers) { client.close(1001, "Server shutting down"); } this.subscribers.clear(); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index e569332..8daa6c8 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -13,6 +13,7 @@ import { AppConfig } from "../config/configuration"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; +import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; const STORE_SIZE_LOG_INTERVAL_MS = 60_000; diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index 082bf7b..514cc0e 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -12,6 +12,16 @@ export class MetricsService implements OnModuleInit { public readonly intentStateTransitions: client.Counter; public readonly wsConnections: client.Gauge; + /** + * Sweeper metrics — these replace the retired src/common/metrics.ts + * MetricsRegistry.sweeper namespace (see issue #259). + * + * The on-call runbook (docs/runbooks/on-call.md) references these names + * directly. Any change here must be reflected there. + */ + public readonly sweeperExpiredTotal: client.Counter; + public readonly sweeperSweepDurationMs: client.Histogram; + constructor(private readonly configService: ConfigService) { this.register = new client.Registry(); const prefix = "vortex_"; @@ -50,6 +60,25 @@ export class MetricsService implements OnModuleInit { help: "Number of active WebSocket connections", registers: [this.register], }); + + // ── Sweeper metrics (issue #259) ───────────────────────────────────────── + // These replace the retired MetricsRegistry.sweeper namespace from + // src/common/metrics.ts. They are Prometheus-backed so they appear in + // GET /metrics and in any Prometheus/Grafana dashboards without further + // adaptation. + + this.sweeperExpiredTotal = new client.Counter({ + name: `${prefix}sweeper_expired_total`, + help: "Total number of intents expired across all sweeps", + registers: [this.register], + }); + + this.sweeperSweepDurationMs = new client.Histogram({ + name: `${prefix}sweeper_sweep_duration_ms`, + help: "Duration of each IntentsSweeperService.sweep() execution in milliseconds", + buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000], + registers: [this.register], + }); } onModuleInit() { @@ -76,4 +105,13 @@ export class MetricsService implements OnModuleInit { decWsConnection() { this.wsConnections.dec(); } + + /** + * Record one sweeper cycle's expired count and duration. + * Called by IntentsSweeperService at the end of every sweep() invocation. + */ + recordSweep(expiredCount: number, durationMs: number): void { + this.sweeperExpiredTotal.inc(expiredCount); + this.sweeperSweepDurationMs.observe(durationMs); + } } diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..0ab1983 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -72,7 +72,7 @@ export class SolversService { async reactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive }; + const updated = { ...solver, isActive: true }; return this.repo.save(updated); } diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index 6f62bce..3e09eeb 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -2,7 +2,10 @@ import { ConfigService } from "@nestjs/config"; import { SolverRegistryService } from "./solver-registry.service"; import { AppConfig } from "../config/configuration"; -function makeConfigService(overrides: Partial = {}) { +function makeConfigService( + overrides: Partial = {}, + appOverrides: Partial> = {}, +) { const stellar: AppConfig["stellar"] = { network: "testnet", sorobanRpcUrl: "https://soroban-testnet.stellar.org", @@ -18,15 +21,16 @@ function makeConfigService(overrides: Partial = {}) { databaseUrl: "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", stellar, onchainIntentsEnabled: false, + // Default to dry-run true for tests (safe default) + onchainDryRun: appOverrides.onchainDryRun ?? true, corsOrigin: "*", wsMaxConnections: 1000, }; return { get: (key: string) => { + if (key === "onchainDryRun") return config.onchainDryRun; const parts = key.split("."); - // only "stellar." keys are used by this service - return (config as unknown as Record)[parts[0]] && - parts[0] === "stellar" + return (config as unknown as Record)[parts[0]] && parts[0] === "stellar" ? (stellar as unknown as Record)[parts[1]] : undefined; }, @@ -46,7 +50,7 @@ describe("SolverRegistryService", () => { expect(service.isConfigured).toBe(false); }); - it("no-ops without contacting the network when unconfigured", async () => { + it("no-ops without contacting the network when unconfigured (dry-run=true)", async () => { const service = new SolverRegistryService(makeConfigService()); const result = await service.slashSolver({ solverAddress: "GSOLVER", @@ -56,6 +60,47 @@ describe("SolverRegistryService", () => { expect(result.submitted).toBe(false); expect(result.simulated).toBe(false); + // In dry-run mode, dryRun flag is true + expect(result.dryRun).toBe(true); + }); +}); + +// ── #260: dry-run flag behaviour ───────────────────────────────────────────── + +describe("SolverRegistryService — dry-run flag (#260)", () => { + it("returns dryRun:true without simulating when ONCHAIN_DRY_RUN=true", async () => { + const service = new SolverRegistryService( + makeConfigService( + { solverRegistryContractId: "CTEST123", signingKey: "S" + "A".repeat(55) }, + { onchainDryRun: true }, + ), + ); + + const result = await service.slashSolver({ + solverAddress: "GSOLVER", + intentId: "intent-1", + reason: "missed deadline", + }); + + expect(result.submitted).toBe(false); + expect(result.dryRun).toBe(true); + expect(result.detail).toMatch(/ONCHAIN_DRY_RUN=true/); + }); + + it("returns dryRun:false when ONCHAIN_DRY_RUN=false and service is not fully configured", async () => { + // With dryRun=false but contract not configured → falls through to no-op + const service = new SolverRegistryService( + makeConfigService({}, { onchainDryRun: false }), + ); + + const result = await service.slashSolver({ + solverAddress: "GSOLVER", + intentId: "intent-1", + reason: "missed deadline", + }); + + expect(result.submitted).toBe(false); + expect(result.dryRun).toBe(false); expect(result.detail).toMatch(/not configured/i); }); }); diff --git a/src/soroban/solver-registry.service.ts b/src/soroban/solver-registry.service.ts index 1499fb1..9b298bb 100644 --- a/src/soroban/solver-registry.service.ts +++ b/src/soroban/solver-registry.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Address, @@ -31,6 +31,12 @@ export interface SlashResult { simulated: boolean; txHash?: string; detail: string; + /** + * true when the result is from a dry-run (ONCHAIN_DRY_RUN=true). + * A dry-run always has submitted=false; it may or may not have simulated=true + * depending on whether the contract is configured. + */ + dryRun: boolean; } /** @@ -46,15 +52,17 @@ export interface SlashResult { * environment today (see src/config/env.validation.ts). * * Wiring an actual submit path is deliberately left for once issue #23 - * confirms the real contract interface and the dry-run flag (issue #35) + * confirms the real contract interface and the dry-run flag (issue #260 / #35) * exists to stage the rollout — see docs/runbooks/onchain-cutover.md. */ @Injectable() export class SolverRegistryService { + private readonly logger = new Logger(SolverRegistryService.name); private readonly contractId: string; private readonly signingKey: string; private readonly networkPassphrase: string; private readonly server: SorobanRpc.Server; + private readonly dryRun: boolean; constructor(configService: ConfigService) { this.contractId = configService.get("stellar.solverRegistryContractId", { infer: true }); @@ -63,6 +71,7 @@ export class SolverRegistryService { this.networkPassphrase = NETWORK_PASSPHRASE[network]; const rpcUrl = configService.get("stellar.sorobanRpcUrl", { infer: true }); this.server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }); + this.dryRun = configService.get("onchainDryRun", { infer: true }); } get isConfigured(): boolean { @@ -70,13 +79,31 @@ export class SolverRegistryService { } async slashSolver(params: SlashParams): Promise { + // ── Dry-run short-circuit (ONCHAIN_DRY_RUN=true) ──────────────────────── + // When dry-run is on, log what *would* be submitted and return immediately + // without touching the network. This is the reference implementation for + // "dry-run output" that all other write paths should mirror. + if (this.dryRun) { + this.logger.log( + `[dry-run] would slash solver=${params.solverAddress} intent=${params.intentId} ` + + `reason="${params.reason}" — ONCHAIN_DRY_RUN=true, no transaction submitted`, + ); + return { + submitted: false, + simulated: false, + dryRun: true, + detail: "ONCHAIN_DRY_RUN=true — simulated log only, no transaction submitted", + }; + } + + // ── Live path (ONCHAIN_DRY_RUN=false) ─────────────────────────────────── if (!this.isConfigured) { const detail = "SOLVER_REGISTRY_CONTRACT_ID or SOROBAN_SIGNING_KEY not configured — no-op"; - console.log( + this.logger.log( `[solver-registry] would slash solver=${params.solverAddress} intent=${params.intentId} reason="${params.reason}" (${detail})`, ); - return { submitted: false, simulated: false, detail }; + return { submitted: false, simulated: false, dryRun: false, detail }; } try { @@ -101,24 +128,30 @@ export class SolverRegistryService { const simulation = await this.server.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simulation)) { const detail = `simulation failed: ${simulation.error}`; - console.error( + this.logger.error( `[solver-registry] slash simulation errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, ); - return { submitted: false, simulated: true, detail }; + return { submitted: false, simulated: true, dryRun: false, detail }; } + // TODO: Once issue #23 confirms the real contract interface, replace + // the simulate-only path below with an actual signed submission: + // const prepared = SorobanRpc.assembleTransaction(tx, simulation); + // sourceKeypair.sign(prepared); + // const result = await this.server.sendTransaction(prepared); const detail = - "simulated only — live submission is gated pending issue #23 (confirmed contract interface) and issue #35 (dry-run/live-mode toggle)"; - console.log( + "simulated only — live submission is gated pending issue #23 (confirmed contract " + + "interface); set ONCHAIN_DRY_RUN=false and wire the submit path to go live"; + this.logger.log( `[solver-registry] simulated slash tx for solver=${params.solverAddress} intent=${params.intentId} (${detail})`, ); - return { submitted: false, simulated: true, detail }; + return { submitted: false, simulated: true, dryRun: false, detail }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); - console.error( + this.logger.error( `[solver-registry] slash call errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, ); - return { submitted: false, simulated: false, detail }; + return { submitted: false, simulated: false, dryRun: false, detail }; } } } diff --git a/src/soroban/stellar-tx.service.spec.ts b/src/soroban/stellar-tx.service.spec.ts index 77450c8..7613901 100644 --- a/src/soroban/stellar-tx.service.spec.ts +++ b/src/soroban/stellar-tx.service.spec.ts @@ -131,4 +131,57 @@ describe("StellarTxService", () => { expect((submittedTx as Transaction).fee).toBe("300"); }); }); + + describe("invokeContract — dry-run mode (#260)", () => { + it("returns dryRun:true without calling any soroban method when dryRun=true", async () => { + // configService returns dryRun=true for onchainDryRun + const dryRunConfigService = { + get: jest.fn((key: string) => { + if (key === "stellar.feePercentile") return "p50"; + if (key === "onchainDryRun") return true; + return undefined; + }), + } as unknown as ConfigService; + + const dryRunService = new StellarTxService( + sorobanService as unknown as SorobanService, + dryRunConfigService, + ); + + const result = await dryRunService.invokeContract({ + contractId: "CTEST", + method: "create_intent", + args: [], + }); + + expect(result.dryRun).toBe(true); + expect(result.status).toBe("DRY_RUN"); + // No network calls should be made in dry-run mode + expect(sorobanService.simulateTransaction).not.toHaveBeenCalled(); + expect(sorobanService.prepareTransaction).not.toHaveBeenCalled(); + }); + + it("throws when dryRun=false (live path not yet implemented)", async () => { + const liveConfigService = { + get: jest.fn((key: string) => { + if (key === "stellar.feePercentile") return "p50"; + if (key === "onchainDryRun") return false; + return undefined; + }), + } as unknown as ConfigService; + + const liveService = new StellarTxService( + sorobanService as unknown as SorobanService, + liveConfigService, + ); + + await expect( + liveService.invokeContract({ + contractId: "CTEST", + method: "create_intent", + args: [], + }), + ).rejects.toThrow(/not yet implemented/); + }); + }); }); diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index fc26e38..0893bc6 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -31,18 +31,25 @@ export interface InvokeContractParams { export interface InvokeContractResult { hash: string; status: string; + /** + * True when the invocation was simulated only (dry-run mode). + * The hash field contains a placeholder — no transaction was broadcast. + */ + dryRun: boolean; } @Injectable() export class StellarTxService { private readonly logger = new Logger(StellarTxService.name); private readonly feePercentile: FeePercentile; + private readonly dryRun: boolean; constructor( private readonly sorobanService: SorobanService, configService: ConfigService, ) { this.feePercentile = configService.get("stellar.feePercentile", { infer: true }); + this.dryRun = configService.get("onchainDryRun", { infer: true }); } /** @@ -105,11 +112,34 @@ export class StellarTxService { /** * Invokes a Soroban contract method. + * + * When ONCHAIN_DRY_RUN is true (the default outside production), the + * call is simulated and logged but never submitted — no funds move and no + * ledger state changes. The returned result carries dryRun: true so callers + * can distinguish simulate-only from live submissions. + * + * When ONCHAIN_DRY_RUN is false, the call builds, signs, and submits the + * actual Soroban transaction. This path requires SOROBAN_SIGNING_KEY and + * the relevant contract IDs to be configured (see env.validation.ts). + * * Used by IntentsService when ONCHAIN_INTENTS_ENABLED is true. - * This is a stub that will be expanded once the on-chain settlement + * Full submit implementation is pending once the on-chain settlement * contract interface is finalised (see docs/architecture/onchain-settlement.md). */ async invokeContract(params: InvokeContractParams): Promise { + if (this.dryRun) { + this.logger.log( + `[dry-run] invokeContract contractId=${params.contractId} method=${params.method} ` + + `— simulating only, ONCHAIN_DRY_RUN=true (no transaction submitted)`, + ); + // Dry-run: return a placeholder result without touching the network. + return { + hash: "dry-run-no-hash", + status: "DRY_RUN", + dryRun: true, + }; + } + this.logger.log( `invokeContract contractId=${params.contractId} method=${params.method}`, ); diff --git a/test/load/ws-broadcast-fanout.test.ts b/test/load/ws-broadcast-fanout.test.ts index 10c441c..4926825 100644 --- a/test/load/ws-broadcast-fanout.test.ts +++ b/test/load/ws-broadcast-fanout.test.ts @@ -128,9 +128,9 @@ async function measureBroadcastLatency( }); // Kick off the broadcast and record the start time *after* the call returns - // (broadcast() is synchronous — it iterates subscribers immediately). + // (broadcast() is async — it resolves after subscriber chain lookups complete). const broadcastStart = Date.now(); - gateway.broadcast({ type: eventType, marker: TARGET_SEQ_MARKER }); + await gateway.broadcast({ type: eventType, marker: TARGET_SEQ_MARKER }); const broadcastEnd = Date.now(); const wallClockMs = broadcastEnd - broadcastStart;