diff --git a/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index 6bca631..967a4ff 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. @@ -208,8 +211,9 @@ is broken — escalate to the service owner rather than scripting the signal. 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/logger.ts b/src/common/logger.ts index ab8e335..b33d118 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -34,7 +34,7 @@ function redactSensitiveFields(info: Record): Record redactSensitiveFields(info) as ReturnType)(); +const redactFormat = format((info) => redactSensitiveFields(info) as unknown as boolean)(); /** * Log-shipping transport, gated behind LOG_SHIPPING_ENABLED so local dev/CI diff --git a/src/common/metrics.ts b/src/common/metrics.ts deleted file mode 100644 index 93b0cbe..0000000 --- a/src/common/metrics.ts +++ /dev/null @@ -1,91 +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 = { - health: { - databaseChecksTotal: new Counter(), - sorobanRpcChecksTotal: new Counter(), - }, - 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 2d33314..7014279 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -93,7 +93,21 @@ export interface AppConfig { feePercentile: FeePercentile; }; onchainIntentsEnabled: boolean; - onchainWritesDryRun: boolean; + intentRetentionDays: number; + intentRetentionSweepMs: number; + /** + * 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; @@ -119,6 +133,11 @@ export default (): AppConfig => ({ onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", intentRetentionDays: parseInt(process.env.INTENT_RETENTION_DAYS ?? "30", 10), intentRetentionSweepMs: parseInt(process.env.INTENT_RETENTION_SWEEP_MS ?? "60000", 10), + // 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), wsBackplane: (process.env.WS_BACKPLANE ?? "memory") as "memory" | "redis", diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index e2a2018..711748b 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -33,6 +33,9 @@ describe("envValidationSchema — SOROBAN_SIGNING_KEY", () => { it("is required in production", () => { const { error } = envValidationSchema.validate({ NODE_ENV: "production", + // ONCHAIN_DRY_RUN is also required in production; include it so this + // test stays focused on SOROBAN_SIGNING_KEY validation only. + ONCHAIN_DRY_RUN: true, }); expect(error).toBeDefined(); expect(error?.message).toContain("SOROBAN_SIGNING_KEY"); @@ -42,6 +45,7 @@ describe("envValidationSchema — SOROBAN_SIGNING_KEY", () => { const { error } = envValidationSchema.validate({ NODE_ENV: "production", SOROBAN_SIGNING_KEY: "", + ONCHAIN_DRY_RUN: true, }); expect(error).toBeDefined(); }); @@ -50,6 +54,9 @@ 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); @@ -101,3 +108,59 @@ describe("envValidationSchema — runtime config flags", () => { expect(error?.message).toContain("SOROBAN_FEE_PERCENTILE"); }); }); + +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 b45818f..f46511d 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -70,7 +70,6 @@ export const envValidationSchema = Joi.object({ // to a live database. Intended for production / staging. INTENTS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), SOLVERS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), - ONCHAIN_WRITES_DRY_RUN: Joi.boolean().default(true), // ── Observability ───────────────────────────────────────────────────────── // Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry. @@ -102,4 +101,36 @@ export const envValidationSchema = Joi.object({ LOG_SHIPPING_PATH: Joi.string().default("/"), LOG_SHIPPING_SSL: Joi.boolean().default(false), LOG_SERVICE_NAME: Joi.string().default("vortex-backend"), + + // ── 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.manual-trigger.spec.ts b/src/intents/intents-sweeper.manual-trigger.spec.ts index 93579d8..9a7eacc 100644 --- a/src/intents/intents-sweeper.manual-trigger.spec.ts +++ b/src/intents/intents-sweeper.manual-trigger.spec.ts @@ -4,6 +4,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"; /** * Issue #269 — the manual sweep trigger (operator break-glass). @@ -24,8 +25,9 @@ describe("IntentsSweeperService — manual sweep trigger (#269)", () => { const solverRegistry = { slashSolver: jest.fn().mockResolvedValue({ detail: "no-op" }), } as unknown as SolverRegistryService; + const metricsService = { recordSweep: jest.fn() } as unknown as MetricsService; - return new IntentsSweeperService(intentsService, gateway, solversService, solverRegistry); + return new IntentsSweeperService(intentsService, gateway, solversService, solverRegistry, metricsService); } afterEach(() => jest.restoreAllMocks()); diff --git a/src/intents/intents-sweeper.service.spec.ts b/src/intents/intents-sweeper.service.spec.ts index 460c5d3..9d28664 100644 --- a/src/intents/intents-sweeper.service.spec.ts +++ b/src/intents/intents-sweeper.service.spec.ts @@ -1,21 +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 { InMemoryIntentsRepository } from "./intents.repository"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; import { AppConfig } from "../config/configuration"; -import { SEED_SOLVER_KEYPAIRS } from "../solvers/solvers.seed"; -const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(); +/** 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(); -async function buildIntentsService(): Promise { +function buildIntentsService(): IntentsService { const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; @@ -26,18 +29,10 @@ async function buildIntentsService(): Promise { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { provide: INTENTS_REPOSITORY, useClass: InMemoryIntentsRepository }, - { provide: ConfigService, useValue: configService }, - { provide: StellarTxService, useValue: stellarTxService }, - { provide: PrismaService, useValue: prismaService }, - IntentsService, - ], - }).compile(); - - return module.get(IntentsService); + 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 { @@ -55,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({ @@ -68,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, ); }); @@ -132,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); @@ -168,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 baa90a7..9c28b20 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -4,6 +4,7 @@ import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; import { SolverRegistryService } from "../soroban/solver-registry.service"; import { logger } from "../common/logger"; +import { MetricsService } from "../metrics/metrics.service"; const SWEEP_INTERVAL_MS = 30_000; @@ -24,6 +25,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { private readonly intentsGateway: IntentsGateway, private readonly solversService: SolversService, private readonly solverRegistryService: SolverRegistryService, + private readonly metricsService: MetricsService, ) {} onModuleInit() { @@ -59,12 +61,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) { @@ -127,7 +133,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { slashReason: reason, }); if (!slashed) return; - 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 @@ -136,7 +142,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { return; } - await this.solversService.recordFailedFill(solver); + await this.solversService.recordFailedFill(solver, intentId); const slashRecord = await this.solversService.recordSlash(solver, intentId, reason, now); const result = await this.solverRegistryService.slashSolver({ diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index d6f876c..023d03f 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -1,12 +1,11 @@ -import { Test } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; import { Keypair } from "@stellar/stellar-sdk"; -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"; import { AppConfig } from "../config/configuration"; -import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; +import { InMemoryIntentsRepository } from "./intents.repository"; import { logger } from "../common/logger"; import { buildWsAuthMessage } from "../common/stellar-signature"; @@ -20,16 +19,6 @@ jest.mock("../common/logger", () => ({ })); function makeIntentsService(): IntentsService { - const repo = { - findAll: jest.fn().mockResolvedValue([]), - save: jest.fn().mockResolvedValue({}), - findById: jest.fn().mockResolvedValue(undefined), - getByState: jest.fn().mockResolvedValue([]), - getByUser: jest.fn().mockResolvedValue([]), - findByIdAndUser: jest.fn().mockResolvedValue(undefined), - update: jest.fn().mockResolvedValue(undefined), - clear: jest.fn(), - }; const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; @@ -39,8 +28,9 @@ function makeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; + const repo = new InMemoryIntentsRepository(); return new IntentsService( - repo as any, + repo, configService, {} as StellarTxService, prismaService, @@ -56,7 +46,7 @@ function makeSolversService() { function createMockClient() { const listeners: Record void> = {}; return { - readyState: 1, + readyState: 1, // WebSocket.OPEN send: jest.fn(), ping: jest.fn(), terminate: jest.fn(), @@ -64,19 +54,80 @@ 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; let solversService: ReturnType; - beforeEach(async () => { + beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); - intentsService = await makeIntentsService(); + intentsService = makeIntentsService(); solversService = makeSolversService(); gateway = new IntentsGateway(intentsService, solversService); }); @@ -141,17 +192,26 @@ 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(); + + await gateway.broadcast({ type: "test_event", data: 123 }); - const expected = JSON.stringify({ type: "test_event", data: 123 }); - expect(c1.send).toHaveBeenCalledWith(expected); - expect(c2.send).toHaveBeenCalledWith(expected); + expect(c1.send).toHaveBeenCalledTimes(1); + expect(c2.send).toHaveBeenCalledTimes(1); + // Both payloads should contain the event type + const payload1 = JSON.parse(c1.send.mock.calls[0][0] as string); + expect(payload1.type).toBe("test_event"); + expect(typeof payload1.seq).toBe("number"); }); it("accepts a valid solver auth message and rejects invalid signatures", async () => { @@ -173,15 +233,17 @@ describe("IntentsGateway heartbeat", () => { }); }); +// ── IntentsGateway logging tests ────────────────────────────────────────── + describe("IntentsGateway logging", () => { let gateway: IntentsGateway; let intentsService: IntentsService; let solversService: ReturnType; - beforeEach(async () => { + beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); - intentsService = await makeIntentsService(); + intentsService = makeIntentsService(); solversService = makeSolversService(); gateway = new IntentsGateway(intentsService, solversService); }); @@ -210,14 +272,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/), ); }); @@ -232,3 +294,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, makeSolversService()); + }); + + afterEach(() => { + gateway.onModuleDestroy(); + jest.useRealTimers(); + }); + + it("responds with subscribed message when client sends valid subscribe", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Simulate incoming subscribe message + client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar", "ethereum"] }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const subscribed = calls.find((m) => m.type === "subscribed"); + expect(subscribed).toBeDefined(); + expect(subscribed.filter.chains).toEqual(expect.arrayContaining(["stellar", "ethereum"])); + }); + + it("strips invalid chain values from subscribe message", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + client._emit("message", Buffer.from(JSON.stringify({ + type: "subscribe", + chains: ["stellar", "invalid_chain", "STELLAR", 123], + }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const subscribed = calls.find((m) => m.type === "subscribed"); + expect(subscribed).toBeDefined(); + // Only "stellar" survives validation + expect(subscribed.filter.chains).toEqual(["stellar"]); + }); + + it("ignores subscribe message with missing chains field", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Should not crash and should not send subscribed + client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe" }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const subscribed = calls.find((m) => m.type === "subscribed"); + expect(subscribed).toBeUndefined(); + }); + + it("ignores malformed JSON without crashing", () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + // Should not throw + expect(() => { + client._emit("message", Buffer.from("not valid json{{{")); + }).not.toThrow(); + }); + + it("delivers intent_created only to subscribed chain clients", async () => { + const stellarClient = createMockClient(); + const ethClient = createMockClient(); + const allClient = createMockClient(); // no subscribe = receives all + + gateway.handleConnection(stellarClient as unknown as import("ws").WebSocket); + gateway.handleConnection(ethClient as unknown as import("ws").WebSocket); + gateway.handleConnection(allClient as unknown as import("ws").WebSocket); + + // Subscribe stellar client to stellar only + stellarClient._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar"] }))); + // Subscribe eth client to ethereum only + ethClient._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["ethereum"] }))); + + stellarClient.send.mockClear(); + ethClient.send.mockClear(); + allClient.send.mockClear(); + + // Broadcast a stellar intent_created + await gateway.broadcast({ + type: "intent_created", + intent: { intentId: "abc", srcChain: "stellar", state: "open" }, + }); + + // stellarClient and allClient should receive it + const stellarCalls = stellarClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const ethCalls = ethClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const allCalls = allClient.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + + expect(stellarCalls.some((m) => m.type === "intent_created")).toBe(true); + expect(ethCalls.some((m) => m.type === "intent_created")).toBe(false); // filtered out + expect(allCalls.some((m) => m.type === "intent_created")).toBe(true); + }); + + it("delivers intent to all subscribers when chain is not resolvable", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client._emit("message", Buffer.from(JSON.stringify({ type: "subscribe", chains: ["stellar"] }))); + client.send.mockClear(); + + // Unknown type with no chain + await gateway.broadcast({ type: "system_announcement", message: "maintenance" }); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + expect(calls.some((m) => m.type === "system_announcement")).toBe(true); + }); + + it("unfiltered client (no subscribe) receives all events", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + await gateway.broadcast({ + type: "intent_created", + intent: { intentId: "xyz", srcChain: "ethereum", state: "open" }, + }); + await gateway.broadcast({ + type: "intent_created", + intent: { intentId: "abc", srcChain: "stellar", state: "open" }, + }); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const created = calls.filter((m) => m.type === "intent_created"); + expect(created).toHaveLength(2); + }); + + it("assigns increasing seq numbers to broadcast events", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + client.send.mockClear(); + + await gateway.broadcast({ type: "e1" }); + await gateway.broadcast({ type: "e2" }); + await gateway.broadcast({ type: "e3" }); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const seqs = calls.map((m: { seq: number }) => m.seq); + // seq values should be strictly increasing + for (let i = 1; i < seqs.length; i++) { + expect(seqs[i]).toBeGreaterThan(seqs[i - 1]); + } + }); +}); + +// ── #258: Event replay ──────────────────────────────────────────────────── + +describe("IntentsGateway — event replay (#258)", () => { + let gateway: IntentsGateway; + let intentsService: IntentsService; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + intentsService = makeIntentsService(); + gateway = new IntentsGateway(intentsService, makeSolversService()); + }); + + afterEach(() => { + gateway.onModuleDestroy(); + jest.useRealTimers(); + }); + + it("returns replay_start, replayed events, and replay_end for valid fromSeq", async () => { + const client = createMockClient(); + gateway.handleConnection(client as unknown as import("ws").WebSocket); + + // Broadcast 3 events so they land in the ring buffer with seq 1, 2, 3 + await gateway.broadcast({ type: "e1" }); + await gateway.broadcast({ type: "e2" }); + await gateway.broadcast({ type: "e3" }); + + client.send.mockClear(); + + // Request replay from seq=1 (expect events with seq > 1 → seq 2 and 3) + client._emit("message", Buffer.from(JSON.stringify({ type: "replay", fromSeq: 1 }))); + + const calls = client.send.mock.calls.map((c) => JSON.parse(c[0] as string)); + const startMsg = calls.find((m) => m.type === "replay_start"); + const endMsg = calls.find((m) => m.type === "replay_end"); + const events = calls.filter((m) => m.type === "e2" || m.type === "e3"); + + expect(startMsg).toBeDefined(); + expect(startMsg.fromSeq).toBe(1); + expect(startMsg.count).toBe(2); + expect(events).toHaveLength(2); + expect(endMsg).toBeDefined(); + expect(endMsg.count).toBe(2); + }); + + it("returns replay_too_old when fromSeq has been evicted from the buffer", async () => { + // Use a tiny ring buffer (capacity 2) to force eviction + const tinyGateway = new IntentsGateway(intentsService, makeSolversService()); + // @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 04fe626..3a92efd 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -5,10 +5,20 @@ import { IntentsService } from "./intents.service"; import { SolversService } from "../solvers/solvers.service"; import { logger } from "../common/logger"; import { SUPPORTED_CHAINS, SupportedChain } from "./intents.types"; +import { verifyStellarSignature, buildWsAuthMessage } from "../common/stellar-signature"; 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 { @@ -17,6 +27,14 @@ export interface SequencedEvent { [key: string]: unknown; } +/** + * Per-subscriber chain filter. `chains: 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. @@ -70,12 +88,15 @@ export class EventRingBuffer { * REST API. The WS gateway never accepts writes, so there is no privileged * action to protect here. */ -type SubscriberFilter = Set | null; - @WebSocketGateway({ path: "/ws" }) export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy { + /** + * 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(); private readonly authenticatedSolver = new WeakMap(); @@ -87,6 +108,9 @@ export class IntentsGateway subscribe: (handler: (event: Record) => void) => void; } = null; + /** Ring buffer storing the last REPLAY_BUFFER_SIZE broadcast events. */ + private readonly ringBuffer = new EventRingBuffer(REPLAY_BUFFER_SIZE); + constructor( private readonly intentsService: IntentsService, private readonly solversService: SolversService, @@ -160,43 +184,20 @@ export class IntentsGateway return typeof value === "string" && (SUPPORTED_CHAINS as readonly string[]).includes(value); } - private static resolveFilter(chains: unknown): SubscriberFilter { - if (!Array.isArray(chains) || chains.length === 0) { - return null; - } - - const normalized = new Set(); - for (const chain of chains) { - if (IntentsGateway.isSupportedChain(chain)) { - normalized.add(chain); - } - } - - return normalized.size > 0 ? normalized : null; - } - - private handleMessage(client: WebSocket, raw: unknown) { - try { - const text = typeof raw === "string" ? raw : raw instanceof Buffer ? raw.toString() : String(raw); - const message = JSON.parse(text) as { type?: string; chains?: unknown }; - if (message.type !== "subscribe") return; - - const filter = IntentsGateway.resolveFilter(message.chains); - this.subscribers.set(client, filter); + private dispatchRemoteEvent(event: Record) { + const type = typeof event.type === "string" ? event.type : ""; + if (!type || type === "connected" || type === "snapshot" || type === "subscribed") return; - client.send( - JSON.stringify({ - type: "subscribed", - filter: { chains: filter ? [...filter] : [...SUPPORTED_CHAINS] }, - seq: this.nextSeq - 1, - }), - ); - } catch { - // Ignore malformed WS frames; the client can reconnect or retry. - } + const payload = JSON.stringify(event); + const chain = this.getEventChainSync(event as { type: string; [key: string]: unknown }); + this.deliverToMatchingSubscribers(payload, chain); } - private getEventChain(event: { type: string; [key: string]: unknown }): SupportedChain | null { + /** + * Synchronous chain resolution for simple cases (used by dispatchRemoteEvent). + * Reads srcChain directly from the event or its inlined intent object. + */ + private getEventChainSync(event: { type: string; [key: string]: unknown }): SupportedChain | null { const intent = (event as { intent?: { srcChain?: unknown } }).intent; if (intent && typeof intent.srcChain === "string" && IntentsGateway.isSupportedChain(intent.srcChain)) { return intent.srcChain; @@ -211,36 +212,40 @@ export class IntentsGateway } private deliverToMatchingSubscribers(payload: string, chain: SupportedChain | null) { - for (const [client, filter] of this.subscribers.entries()) { + for (const [client, filter] of this.subscribers) { if (client.readyState !== WebSocket.OPEN) continue; - if (chain !== null && filter && !filter.has(chain)) continue; - client.send(payload); - } - } - private dispatchRemoteEvent(event: Record) { - const type = typeof event.type === "string" ? event.type : ""; - if (!type || type === "connected" || type === "snapshot" || type === "subscribed") return; + // No filter set → full unfiltered feed (backward-compatible default). + if (filter.chains === null) { + client.send(payload); + continue; + } - const payload = JSON.stringify(event); - const chain = this.getEventChain(event as { type: string; [key: string]: unknown }); - this.deliverToMatchingSubscribers(payload, chain); + // Chain couldn't be resolved → deliver to everyone (safe default). + if (chain === null) { + client.send(payload); + continue; + } + + // Only send if the event's chain is in this subscriber's filter. + if (filter.chains.has(chain)) { + client.send(payload); + } + } } handleConnection(client: WebSocket) { - this.subscribers.set(client, null); + this.subscribers.set(client, { chains: null }); this.alive.set(client, true); - client.on("message", (raw) => this.handleMessage(client, raw)); + client.on("message", (raw) => { + void this.handleMessage(client, raw); + }); client.on("pong", () => { this.alive.set(client, true); }); - client.on("message", (raw) => { - void this.handleMessage(client, raw); - }); - client.on("error", () => { this.subscribers.delete(client); logger.debug( @@ -277,29 +282,144 @@ export class IntentsGateway logger.info(`ws client disconnected (subscribers=${this.subscribers.size})`); } - private async handleMessage(client: WebSocket, raw: unknown) { + /** + * 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. + * - `{ type: "auth", solver, timestamp, signature }` — authenticate as a + * registered solver. + * + * Unknown types and malformed messages are silently ignored; they never + * crash the connection. + */ + private async handleMessage(client: WebSocket, raw: import("ws").RawData): Promise { + let parsed: unknown; try { - const serialized = Buffer.isBuffer(raw) - ? raw.toString("utf8") - : typeof raw === "string" - ? raw - : String(raw); - const payload = JSON.parse(serialized); - if (!payload || typeof payload !== "object") return; - - switch (payload.type) { - case "auth": { - await this.handleAuth(client, payload); - return; - } - default: - return; - } + parsed = JSON.parse(raw.toString()); } catch { - client.send(JSON.stringify({ type: "auth_error", reason: "Invalid WS message" })); + // 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; + case "auth": + await this.handleAuth(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. + */ + 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}`); + } + private async handleAuth(client: WebSocket, payload: Record) { const solver = typeof payload.solver === "string" ? payload.solver : ""; const timestamp = payload.timestamp; @@ -332,39 +452,86 @@ export class IntentsGateway } } - broadcast(event: { type: string; [key: string]: unknown }) { - const seqEvent = { ...event, seq: this.nextSeq }; - this.nextSeq += 1; - logger.debug(`ws broadcast type=${event.type} subscribers=${this.subscribers.size}`); - const payload = JSON.stringify(seqEvent); - const chain = this.getEventChain(seqEvent); - - if (this.backplane) { - this.backplane.publish(seqEvent as Record); + /** + * 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; } - if (chain) { - this.deliverToMatchingSubscribers(payload, chain); - return; + 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; } - if (typeof event.intentId === "string") { - void this.intentsService - .get(event.intentId) - .then((intent) => { - if (!intent) { - this.deliverToMatchingSubscribers(payload, null); - return; - } - this.deliverToMatchingSubscribers(payload, intent.srcChain); - }) - .catch(() => { - this.deliverToMatchingSubscribers(payload, null); - }); - return; + 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}`); + + if (this.backplane) { + this.backplane.publish(sequencedEvent as Record); } - this.deliverToMatchingSubscribers(payload, null); + // Resolve the chain once — shared across all subscriber checks. + const eventChain = await this.getEventChain(event); + + const payload = JSON.stringify(sequencedEvent); + this.deliverToMatchingSubscribers(payload, eventChain); } getAliveCount(): number { @@ -385,7 +552,7 @@ export class IntentsGateway } private heartbeat() { - for (const [client] of this.subscribers.entries()) { + for (const [client] of this.subscribers) { if (this.alive.get(client) === false) { client.terminate(); this.subscribers.delete(client); @@ -404,7 +571,7 @@ export class IntentsGateway onModuleDestroy() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); - for (const client of this.subscribers.keys()) { + 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 cf03885..d1eaa73 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -19,7 +19,6 @@ import { } 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; const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slashed"]; 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 7ba9358..ce9aafd 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Logger } from "@nestjs/common"; import { SupportedChain } from "../intents/intents.types"; import { SOLVERS_REPOSITORY, ISolversRepository } from "./solvers.repository"; import { SolverRecord, SolverPendingPenalty } from "./solvers.types"; @@ -31,7 +31,9 @@ export interface SlashRecord { */ @Injectable() export class SolversService { + private readonly logger = new Logger(SolversService.name); private readonly slashHistory = new Map(); + private readonly pendingPenalties = new Map(); private slashSequence = 0; constructor( diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index 6eb898a..3d0075f 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -2,12 +2,16 @@ 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", settlementContractId: "", solverRegistryContractId: "", + signerSecretKey: "", signingKey: "", feePercentile: "p50", ...overrides, @@ -18,16 +22,20 @@ function makeConfigService(overrides: Partial = {}) { databaseUrl: "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", stellar, onchainIntentsEnabled: false, - onchainWritesDryRun: true, + intentRetentionDays: 30, + intentRetentionSweepMs: 60000, + // Default to dry-run true for tests (safe default) + onchainDryRun: appOverrides.onchainDryRun ?? true, corsOrigin: "*", wsMaxConnections: 1000, + wsBackplane: "memory", + redisUrl: "redis://localhost:6379", }; 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; }, @@ -47,7 +55,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", @@ -57,6 +65,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 db089de..0604a97 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, @@ -11,7 +11,6 @@ import { nativeToScVal, } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; -import { logger } from "../common/logger"; import { SignerService } from "./signer.service"; const NETWORK_PASSPHRASE: Record = { @@ -33,19 +32,33 @@ 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; } /** * Client for the on-chain solver-registry contract's penalty path. * - * The service builds the contract call, simulates it, and when the dry-run flag - * is disabled it signs and submits the transaction using the configured Soroban - * signer. This preserves the "do not let a bad record explode the sweep cycle" - * guarantee by returning structured SlashResult values on any failure instead of - * throwing. + * There is no deployed solver-registry contract or confirmed function + * signature yet (tracked separately — issue #23 wires solver acceptance to + * this same contract). Until that lands, this service simulates the call + * it *would* make and never submits — safe by construction, since + * SorobanRpc's simulateTransaction never mutates ledger state. It also + * fails closed to a pure no-op whenever the registry contract ID or the + * backend's signing key isn't configured, which is the default in every + * 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 #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; @@ -62,7 +75,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 = Boolean(configService.get("onchainWritesDryRun", { infer: true })); + this.dryRun = configService.get("onchainDryRun", { infer: true }); } get isConfigured(): boolean { @@ -70,13 +83,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"; - logger.info( + 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 { @@ -103,45 +134,32 @@ export class SolverRegistryService { const simulation = await this.server.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simulation)) { const detail = `simulation failed: ${simulation.error}`; - logger.error( + this.logger.error( `[solver-registry] slash simulation errored for solver=${params.solverAddress} intent=${params.intentId}: ${detail}`, ); - return { submitted: false, simulated: true, detail }; - } - - if (this.dryRun) { - const detail = "dry-run enabled — simulated only, transaction not submitted"; - logger.info( - `[solver-registry] simulated slash tx for solver=${params.solverAddress} intent=${params.intentId} (${detail})`, - ); - return { submitted: false, simulated: true, detail }; - } - - const signedTx = this.signerService ? this.signerService.sign(tx) : tx.sign(sourceKeypair); - const response = await this.server.sendTransaction(signedTx); - - if (response.status === "PENDING" || response.status === "SUCCESS") { - const txHash = response.hash || "unknown"; - const detail = `submitted via ${response.status}`; - logger.info( - `[solver-registry] submitted slash tx for solver=${params.solverAddress} intent=${params.intentId} txHash=${txHash} (${detail})`, - ); - return { submitted: true, simulated: true, txHash, detail }; + return { submitted: false, simulated: true, dryRun: false, detail }; } - const detail = `submission failed: status=${response.status}`; - logger.error( - `[solver-registry] slash broadcast failed for solver=${params.solverAddress} intent=${params.intentId}: ${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); 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) { // Issue #300 — the SDK may include serialized transaction/XDR details in // thrown errors; do not log the signing key or any raw secret here. const detail = err instanceof Error ? err.message : String(err); - logger.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 682c544..93c4a92 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 }); } /** @@ -108,11 +115,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;