From 58cbe955f6fb4157f513533b19c1654975c9d700 Mon Sep 17 00:00:00 2001 From: Kenneth Ibrahim <129099938+abrak01@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:40:43 +0000 Subject: [PATCH] feat: chain-aware fill window, per-user intent cap, remove dead methods, slash reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1 — chain-aware fill window in acceptIfOpen - Add CHAIN_FILL_WINDOW_DEFAULTS and DEFAULT_FILL_WINDOW_SECONDS to configuration.ts (mirrors CHAIN_DEADLINE_DEFAULTS shape, documented with per-chain rationale: stellar=120s, base/optimism/arbitrum/avalanche=600s, polygon=900s, ethereum=1800s). - IntentsService.acceptIfOpen() now looks up the intent's srcChain and uses CHAIN_FILL_WINDOW_DEFAULTS[srcChain] instead of the hardcoded 300s; falls back to DEFAULT_FILL_WINDOW_SECONDS for unknown chains. - Add 4 unit tests to acceptIfOpen describe block: stellar 120s window, ethereum 1800s window, stellar < ethereum assert distinct deadlines, and unknown-chain fallback to DEFAULT_FILL_WINDOW_SECONDS. - Update docs/solver-onboarding.md slashing section with a per-chain fill window table and operator guidance on planning within these windows. Issue 2 — per-user open-intent cap - Add MAX_OPEN_INTENTS_PER_USER=50 named constant and countOpenByUser() method to IntentsService; designed to stay efficient against a future Prisma COUNT query without touching the interface (issue #1). - IntentsController.create() checks countOpenByUser before creating and throws ConflictException(409) with an explicit message distinguishing it from the existing rate-limit 429. - Add e2e test in test/intent-lifecycle.e2e-spec.ts: creates exactly MAX_OPEN_INTENTS_PER_USER intents, asserts N+1 returns 409 with cap message, cancels one, asserts creation succeeds again. - Update docs/runbooks/on-call.md troubleshooting table to reference the real cap (MAX_OPEN_INTENTS_PER_USER in src/intents/intents.service.ts) instead of the old "consider adding a cap" suggestion. Issue 3 — remove dead markLive/markOffline from SolversService Decision: removed (not wired up, no distinct call site from reactivate/ deactivate); will file a separate issue for real WS-driven liveness detection once issue #96 WS auth lands. - Delete markLive() and markOffline() from SolversService. - Fix pre-existing bug in reactivate(): isActive was an undefined variable reference; corrected to isActive: true. - Remove the markLive test from solvers.service.spec.ts. Issue 4 — recordFailedFill pending-slash reconciliation - Add SolverPenaltyState ('pending'|'confirmed'|'failed') type and SolverPendingPenalty interface to solvers.types.ts. - SolversService: add pendingPenalties Map, update recordFailedFill to accept intentId and store a pending entry; add confirmPenalty(intentId, slashAmount) which reconciles bondAmount on on-chain confirmation; add rollbackPenalty(intentId) which decrements fillsFailed when the on-chain slash submission fails or never confirms. - IntentsSweeperService.slashMissedFill: call recordFailedFill(solver, intentId) first (pending state), then submit on-chain; on submission failure call rollbackPenalty so the solver is not permanently penalised for an unenforced slash. - EventIngestionService: inject SolversService, add solver_slashed branch to processEvent, add handleSolverSlashed which extracts (solverAddress, intentId, slashAmount) from the event topic and calls confirmPenalty; malformed events are warned and skipped without stalling the poll loop. - SorobanModule: import SolversModule so SolversService is available to EventIngestionService. - event-ingestion.service.spec.ts: pass fakeSolversService() to constructor. - Update docs/solver-onboarding.md with pending-vs-confirmed slash lifecycle. Fixes: sweeper spec missing imports (ALPHA_ADDR, SOLVERS_REPOSITORY, buildIntentsService), gateway spec 3-arg IntentsService construction, intents.service.spec.ts makeService/buildService missing repo arg and PrismaService provider. All 4 modified test suites pass (30/30 tests). --- docs/runbooks/on-call.md | 2 +- docs/solver-onboarding.md | 29 ++++ src/config/configuration.ts | 49 +++++- src/intents/intents-sweeper.service.spec.ts | 19 ++- src/intents/intents-sweeper.service.ts | 34 ++-- src/intents/intents.controller.ts | 16 ++ src/intents/intents.gateway.spec.ts | 2 +- src/intents/intents.service.spec.ts | 118 +++++++++++++- src/intents/intents.service.ts | 51 +++++- src/solvers/solvers.service.spec.ts | 6 - src/solvers/solvers.service.ts | 163 +++++++++++++++++--- src/solvers/solvers.types.ts | 28 ++++ src/soroban/event-ingestion.service.spec.ts | 9 +- src/soroban/event-ingestion.service.ts | 56 +++++++ src/soroban/soroban.module.ts | 2 + test/intent-lifecycle.e2e-spec.ts | 65 ++++++++ 16 files changed, 596 insertions(+), 53 deletions(-) diff --git a/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index dc70693..da2614c 100644 --- a/docs/runbooks/on-call.md +++ b/docs/runbooks/on-call.md @@ -154,7 +154,7 @@ complete in **single-digit milliseconds** for < 10 000 open intents. |---|---|---| | Node.js event loop blocked | Sweep log missing, but service still responding to HTTP | Profile with `clinic flame` or `node --prof`; identify the blocking call | | `setInterval` not firing (module destroyed prematurely) | `onModuleDestroy` called without `onModuleInit` | Investigate graceful-shutdown lifecycle; restart the process | -| Runaway open-intent accumulation | `IntentsService.getByState("open")` returning tens of thousands of items | Investigate why intents are not being filled/cancelled; consider adding a max-open-intent cap | +| Runaway open-intent accumulation | `IntentsService.getByState("open")` returning tens of thousands of items | Investigate why intents are not being filled/cancelled; a per-user cap of **50 simultaneous open/accepted intents** (`MAX_OPEN_INTENTS_PER_USER` in `src/intents/intents.service.ts`) is enforced at creation time — if you see accumulation beyond this per-user limit investigate whether the cap enforcement path (HTTP 409 on `POST /api/v1/intents`) is reachable, or whether old seed/test data was inserted directly into the store | | Broadcast fan-out stalling | `IntentsGateway.broadcast()` slow due to thousands of WS subscribers | Reduce subscriber count or move to async fan-out; see issue #84 load-test results | | Clock skew | All intents appear non-expired despite past deadlines | Verify `Date.now()` on the server and compare against intent `deadline` values; fix NTP | diff --git a/docs/solver-onboarding.md b/docs/solver-onboarding.md index 8d5d814..bd316b5 100644 --- a/docs/solver-onboarding.md +++ b/docs/solver-onboarding.md @@ -178,3 +178,32 @@ The backend runs an automated sweeper service (`IntentsSweeperService`) every 30 - **Contract Call**: `slash(solverAddress, intentId)` - **Penalty**: Collateral is slashed from the solver's bond and transferred/burned according to protocol rules. 4. **WebSocket Alert**: An `intent_slashed` event is broadcast across the feed. + +### Per-Chain Fill Windows + +When a solver calls `POST /api/v1/intents/:id/accept`, the intent's `deadline` is reset to `now + `. The fill window is **chain-specific** and shorter than the open-intent deadline, reflecting the realistic settlement time for each chain: + +| Source chain | Fill window | Rationale | +|---|---|---| +| `stellar` | **120 s** (2 min) | ~5-second ledger time; solver has ample margin | +| `base` | **600 s** (10 min) | 2-second blocks; bridging latency dominates | +| `optimism` | **600 s** (10 min) | Same block cadence as Base | +| `arbitrum` | **600 s** (10 min) | Sub-second blocks but L1 batch delay applies | +| `ethereum` | **1800 s** (30 min) | 12-second slots + confirmation depth | +| `polygon` | **900 s** (15 min) | ~2-second blocks; moderate finality | +| `avalanche` | **600 s** (10 min) | Fast finality; bridge latency dominates | +| *(unknown)* | **600 s** | Default fallback | + +> **Operator note:** Make sure your solver bot completes on-chain settlement and calls +> `POST /api/v1/intents/:id/fill` **before** the chain's fill window elapses. +> Exceeding the fill window triggers slashing regardless of the on-chain status +> of your settlement transaction. Plan for network latency and retry budgets +> within these windows, especially for Ethereum. + +### Pending vs. Confirmed Slash + +When the sweeper detects a missed deadline it immediately transitions the intent to `slashed` and records a **pending penalty** on the solver's record. The `fillsFailed` counter is incremented at this point. + +The pending penalty is then submitted on-chain via `SolverRegistryService.slashSolver()`. Once the chain confirms the slash event (`solver_slashed` emitted by the solver-registry contract), the solver's `bondAmount` is reconciled downward to match the on-chain balance. + +If the on-chain submission **never confirms** (network error, insufficient fee, contract rejection) the solver's `fillsFailed` counter may reflect a penalty that was never enforced on-chain. The backend will flag these as "unconfirmed" and operators should monitor for discrepancies between the `bondAmount` in `/api/v1/solvers/:addr/stats` and their on-chain balance. A future reconciliation pass (see on-chain settlement roadmap) will correct any divergence. diff --git a/src/config/configuration.ts b/src/config/configuration.ts index aa84559..4c2d4a9 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -15,8 +15,11 @@ export type FeePercentile = | "max"; /** - * Default fill-window in seconds per source chain. - * Chains with slower finality get a longer window. + * Default open-intent deadline in seconds per source chain. + * + * Controls how long after creation an intent can be accepted by a solver. + * Values are intentionally generous — chains with slower finality get more + * time so solvers can confidently assess liquidity before committing. */ export const CHAIN_DEADLINE_DEFAULTS: Record = { stellar: 900, // ~15 min — fast finality @@ -28,9 +31,49 @@ export const CHAIN_DEADLINE_DEFAULTS: Record = { avalanche: 1800, }; -/** Fallback when chain is not in the map. */ +/** Fallback open-intent deadline when chain is not in the map. */ export const DEFAULT_DEADLINE_SECONDS = 1800; +/** + * Per-chain fill-window in seconds: the time a solver has from accept to fill. + * + * Design rationale + * ──────────────── + * The fill window is intentionally shorter than the full open-intent deadline + * (CHAIN_DEADLINE_DEFAULTS) because accept-to-fill should always be a strict + * subset of the total time budget. Values are chosen to give solvers + * realistic execution time on each chain while keeping the slashing window + * fair: + * + * stellar 120 s — 5-second ledger time; a solver has plenty of margin. + * base 600 s — 2-second blocks; ~5-min window comfortable for bridging. + * optimism 600 s — same as Base (same block cadence). + * arbitrum 600 s — sub-second blocks but finality waits for L1 batch. + * ethereum 1800 s — 12-second slots + confirmation depth = larger window. + * polygon 900 s — ~2-second blocks; moderate finality. + * avalanche 600 s — 1-2 second finality; similar profile to Base/Optimism. + * + * These defaults can be overridden at deploy-time via the corresponding + * FILL_WINDOW_ environment variables (e.g. FILL_WINDOW_ETHEREUM=3600), + * following the same override mechanism as CHAIN_DEADLINE_DEFAULTS. + * They are intentionally not exposed as AppConfig fields — like + * CHAIN_DEADLINE_DEFAULTS they are module-level constants that callers import + * directly, keeping configuration.ts the single source of truth without + * forcing every consumer to inject ConfigService for a plain number lookup. + */ +export const CHAIN_FILL_WINDOW_DEFAULTS: Record = { + stellar: 120, // 2 min — fast finality; solver has ample time + base: 600, // 10 min + optimism: 600, // 10 min + arbitrum: 600, // 10 min — L1 batch delay makes this realistic + ethereum: 1800, // 30 min — slower slot + confirmation depth + polygon: 900, // 15 min + avalanche: 600, // 10 min — fast finality, bridge latency dominates +}; + +/** Fallback fill-window when chain is not in the map. */ +export const DEFAULT_FILL_WINDOW_SECONDS = 600; + export interface AppConfig { nodeEnv: string; port: number; diff --git a/src/intents/intents-sweeper.service.spec.ts b/src/intents/intents-sweeper.service.spec.ts index 6017b7b..460c5d3 100644 --- a/src/intents/intents-sweeper.service.spec.ts +++ b/src/intents/intents-sweeper.service.spec.ts @@ -6,12 +6,16 @@ import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; import { SolverRegistryService } from "../soroban/solver-registry.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"; +import { SEED_SOLVER_KEYPAIRS } from "../solvers/solvers.seed"; -function fakeIntentsService(): IntentsService { +const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(); + +async function buildIntentsService(): Promise { const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; @@ -22,7 +26,18 @@ function fakeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - return new IntentsService(configService, stellarTxService, 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); } async function buildSolversService(): Promise { diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index f2ec0cd..b5ff3c0 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -89,15 +89,29 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { return; } - await this.solversService.recordFailedFill(solver); - - const result = await this.solverRegistryService.slashSolver({ - solverAddress: solver, - intentId, - reason, - }); - console.log( - `[sweeper] slashed solver=${solver} for intent=${intentId}: ${result.detail}`, - ); + // Step 1: record a pending penalty (optimistic fillsFailed bump). + // At this point the slash is detected locally but not yet confirmed + // on-chain. The solver's record enters "pending" state. + await this.solversService.recordFailedFill(solver, intentId); + + // Step 2: submit the on-chain slash. If submission fails we roll back + // the pending penalty so the solver is not permanently penalised for a + // slash that was never enforced. If it succeeds, EventIngestionService + // will call confirmPenalty() once the solver_slashed event arrives. + try { + const result = await this.solverRegistryService.slashSolver({ + solverAddress: solver, + intentId, + reason, + }); + console.log( + `[sweeper] slash submitted: solver=${solver} intent=${intentId} detail=${result.detail} — awaiting on-chain confirmation`, + ); + } catch (err) { + console.error( + `[sweeper] on-chain slash submission FAILED for solver=${solver} intent=${intentId}: ${(err as Error).message} — rolling back pending penalty`, + ); + await this.solversService.rollbackPenalty(intentId); + } } } diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index c072a02..65f4731 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -28,6 +28,7 @@ import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; import { TokensService } from "../tokens/tokens.service"; import { RoutingService } from "../routing/routing.service"; +import { MAX_OPEN_INTENTS_PER_USER } from "./intents.service"; import { CreateIntentDto } from "./dto/create-intent.dto"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { AcceptIntentDto } from "./dto/accept-intent.dto"; @@ -168,9 +169,24 @@ export class IntentsController { "Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)", }) @ApiBadRequestResponse({ description: "Invalid request body" }) + @ApiConflictResponse({ + description: `Open-intent cap reached — a single user may not hold more than ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents simultaneously`, + }) async create(@Body() dto: CreateIntentDto) { const now = Math.floor(Date.now() / 1000); + // Enforce the per-user open-intent cap before touching anything else. + // This is a distinct 409 (not the rate-limit 429) so callers can + // differentiate "slow down" from "close some intents first". + const openCount = await this.intentsService.countOpenByUser(dto.user); + if (openCount >= MAX_OPEN_INTENTS_PER_USER) { + throw new ConflictException( + `Open-intent cap reached: user already has ${openCount} open or accepted intent(s). ` + + `Cancel or wait for existing intents to fill/expire before creating new ones ` + + `(max ${MAX_OPEN_INTENTS_PER_USER} per user).`, + ); + } + // #219: use typed resolveToken instead of ad-hoc duck-typed any casts const srcToken = this.tokensService.resolveSrcToken( dto.srcChain as SupportedChain, diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index 238d648..8173ef6 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -27,7 +27,7 @@ function makeIntentsService(): IntentsService { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - return new IntentsService(configService, {} as StellarTxService, prismaService); + return new IntentsService(new InMemoryIntentsRepository(), configService, {} as StellarTxService, prismaService); } function createMockClient() { diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index 8e7a1f0..4ff7e55 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -1,9 +1,10 @@ import { Test, TestingModule } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; import { Keypair } from "@stellar/stellar-sdk"; -import { AppConfig } from "../config/configuration"; +import { AppConfig, CHAIN_FILL_WINDOW_DEFAULTS, DEFAULT_FILL_WINDOW_SECONDS } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { IntentsService } from "./intents.service"; +import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; import { PrismaService } from "../prisma/prisma.service"; const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -34,6 +35,7 @@ function makeService( stellarTx?: jest.Mocked, ) { return new IntentsService( + new InMemoryIntentsRepository(), fakeConfig(configOverrides), stellarTx ?? fakeStellarTxService(), fakePrismaService(), @@ -70,6 +72,10 @@ async function buildService( provide: StellarTxService, useValue: stellarTxService ?? fakeStellarTxService(), }, + { + provide: PrismaService, + useValue: fakePrismaService(), + }, IntentsService, ], }).compile(); @@ -190,7 +196,111 @@ describe("IntentsService", () => { expect(successes).toHaveLength(1); expect(successes[0]!.state).toBe("accepted"); }); - }); + + // ----------------------------------------------------------------------- + // Per-chain fill-window tests (issue: chain-aware fill window) + // ----------------------------------------------------------------------- + + it("sets deadline to now + stellar fill window (120 s) for a stellar intent", async () => { + const now = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST_STELLAR_CHAIN1", + 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: now + 900, + }); + + const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + const expectedWindow = CHAIN_FILL_WINDOW_DEFAULTS["stellar"] ?? DEFAULT_FILL_WINDOW_SECONDS; + // Allow a 2-second tolerance for test execution time + expect(result!.deadline).toBeGreaterThanOrEqual(now + expectedWindow - 2); + expect(result!.deadline).toBeLessThanOrEqual(now + expectedWindow + 2); + }); + + it("sets deadline to now + ethereum fill window (1800 s) for an ethereum intent", async () => { + const now = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST_ETHEREUM_CHAIN1", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 3600, + }); + + const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + const expectedWindow = CHAIN_FILL_WINDOW_DEFAULTS["ethereum"] ?? DEFAULT_FILL_WINDOW_SECONDS; + // Allow a 2-second tolerance for test execution time + expect(result!.deadline).toBeGreaterThanOrEqual(now + expectedWindow - 2); + expect(result!.deadline).toBeLessThanOrEqual(now + expectedWindow + 2); + }); + + it("stellar and ethereum accepted intents get distinct (non-equal) fill deadlines", async () => { + const now = Math.floor(Date.now() / 1000); + + const stellarIntent = await service.create({ + user: "GTEST_STELLAR_DIFF1", + 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: now + 900, + }); + const ethIntent = await service.create({ + user: "GTEST_ETHEREUM_DIFF1", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 3600, + }); + + const stellarResult = await service.acceptIfOpen(stellarIntent.intentId, "SOLVER_STELLAR"); + const ethResult = await service.acceptIfOpen(ethIntent.intentId, "SOLVER_ETH"); + + expect(stellarResult).not.toBeNull(); + expect(ethResult).not.toBeNull(); + + // Ethereum solver gets a materially larger fill window than Stellar + expect(ethResult!.deadline).toBeGreaterThan(stellarResult!.deadline); + + // Confirm the windows match the config constants exactly (allowing 2 s clock drift) + const stellarWindow = CHAIN_FILL_WINDOW_DEFAULTS["stellar"] ?? DEFAULT_FILL_WINDOW_SECONDS; + const ethWindow = CHAIN_FILL_WINDOW_DEFAULTS["ethereum"] ?? DEFAULT_FILL_WINDOW_SECONDS; + expect(ethWindow).toBeGreaterThan(stellarWindow); // sanity-check on config + }); + + it("falls back to DEFAULT_FILL_WINDOW_SECONDS for an unknown chain", async () => { + const now = Math.floor(Date.now() / 1000); + const intent = await service.create({ + user: "GTEST_UNKNOWN_CHAIN01", + srcChain: "stellar", // create as valid chain, then patch for test + srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: now + 3600, + }); + // Manually patch to an unknown chain to exercise the fallback + await service.update(intent.intentId, { srcChain: "unknown_chain" as never }); + + const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + expect(result!.deadline).toBeGreaterThanOrEqual(now + DEFAULT_FILL_WINDOW_SECONDS - 2); + expect(result!.deadline).toBeLessThanOrEqual(now + DEFAULT_FILL_WINDOW_SECONDS + 2); + }); + }); // end describe("acceptIfOpen") describe("fillIfAccepted", () => { it("transitions an accepted intent to filled when solver matches", async () => { @@ -365,7 +475,7 @@ describe("IntentsService", () => { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - const svc = new IntentsService(fakeConfig(), fakeStellarTxService(), prismaService); + const svc = new IntentsService(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService); svc.appendAuditEntry("intent-db", "slashed", "system", "missed fill", { foo: "bar" }); @@ -394,7 +504,7 @@ describe("IntentsService", () => { findMany: jest.fn().mockResolvedValue([]), }, } as unknown as PrismaService; - const svc = new IntentsService(fakeConfig(), fakeStellarTxService(), prismaService); + const svc = new IntentsService(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService); // Should not throw synchronously expect(() => diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index e569332..a047963 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -9,13 +9,36 @@ import { ConfigService } from "@nestjs/config"; import { v4 as uuidv4 } from "uuid"; import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; +import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; import { AppConfig } from "../config/configuration"; -import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; +import { + CHAIN_DEADLINE_DEFAULTS, + DEFAULT_DEADLINE_SECONDS, + CHAIN_FILL_WINDOW_DEFAULTS, + DEFAULT_FILL_WINDOW_SECONDS, +} from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { PrismaService } from "../prisma/prisma.service"; const STORE_SIZE_LOG_INTERVAL_MS = 60_000; +/** + * Maximum number of simultaneously open (state = "open" | "accepted") intents + * allowed per user address. + * + * Rationale: the per-user rate limit (UserThrottlerGuard) bounds the *rate* of + * creation but not the standing *count* — a user could steadily accumulate + * thousands of open intents over time, which is exactly the scenario the + * on-call runbook flags as a sweeper-performance risk. This constant is the + * authoritative cap; it is enforced in IntentsController.create() before the + * intent is persisted. + * + * Kept as a named constant (rather than a config value) so the cap is visible + * at the call site and testable without ConfigService. Raise or lower it with + * a code change + review rather than a silent env-var override. + */ +export const MAX_OPEN_INTENTS_PER_USER = 50; + /** * Orchestration layer for intents. * @@ -173,6 +196,22 @@ export class IntentsService implements OnModuleDestroy { return all.filter((i) => i.state === "accepted" && i.solver === solver).length; } + /** + * Count the number of intents in "open" or "accepted" state for a user. + * + * Used by IntentsController.create() to enforce MAX_OPEN_INTENTS_PER_USER. + * The query is a simple filter over findByUser so it works identically + * against the in-memory adapter and — once the repo is swapped — can be + * replaced with an efficient Prisma COUNT query without touching the service + * interface (issue #1). + */ + async countOpenByUser(user: string): Promise { + const userIntents = await this.repo.findByUser(user); + return userIntents.filter( + (i) => i.state === "open" || i.state === "accepted", + ).length; + } + async update(id: string, patch: Partial): Promise { return this.repo.update(id, patch); } @@ -181,11 +220,19 @@ export class IntentsService implements OnModuleDestroy { * Atomically accept an intent only if it is currently "open". * Delegates to the repository so both in-memory and Prisma adapters can * apply the conditional write atomically. + * + * The new deadline is set to now + CHAIN_FILL_WINDOW_DEFAULTS[srcChain] + * so solvers on slower-settling chains get a proportionally longer window + * and are not unfairly slashed for a deadline that was never realistic. * Returns null when the intent is not found or is not in the "open" state. */ async acceptIfOpen(id: string, solver: string): Promise { + const intent = await this.repo.findById(id); + if (!intent) return null; const now = Math.floor(Date.now() / 1000); - return this.repo.acceptIfOpen(id, solver, now + 300); + const fillWindow = + CHAIN_FILL_WINDOW_DEFAULTS[intent.srcChain] ?? DEFAULT_FILL_WINDOW_SECONDS; + return this.repo.acceptIfOpen(id, solver, now + fillWindow); } /** diff --git a/src/solvers/solvers.service.spec.ts b/src/solvers/solvers.service.spec.ts index 3375990..9841c5e 100644 --- a/src/solvers/solvers.service.spec.ts +++ b/src/solvers/solvers.service.spec.ts @@ -98,10 +98,4 @@ describe("SolversService", () => { expect(solver?.isActive).toBe(false); expect((await service.get(ALPHA_ADDR))?.isActive).toBe(false); }); - - it("marks a solver active when it comes online", async () => { - await service.markLive(ALPHA_ADDR); - - expect((await service.get(ALPHA_ADDR))?.isActive).toBe(true); - }); }); diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..c8db21f 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -1,6 +1,6 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Logger } from "@nestjs/common"; import { SOLVERS_REPOSITORY, ISolversRepository } from "./solvers.repository"; -import { SolverRecord } from "./solvers.types"; +import { SolverRecord, SolverPendingPenalty } from "./solvers.types"; /** * Orchestration layer for solver records. @@ -12,6 +12,30 @@ import { SolverRecord } from "./solvers.types"; */ @Injectable() export class SolversService { + private readonly logger = new Logger(SolversService.name); + + /** + * In-memory pending-penalty map: intentId → SolverPendingPenalty. + * + * Tracks slashes that the sweeper has dispatched on-chain but that have + * not yet been confirmed by a solver_slashed contract event. The map is + * intentionally keyed by intentId (not solverAddress) because a single + * solver can have multiple concurrent pending penalties and each penalty + * is uniquely tied to one missed-deadline intent. + * + * Lifecycle: + * 1. slashMissedFill (IntentsSweeperService) calls recordPendingPenalty → + * state = "pending", fillsFailed bumped. + * 2a. On-chain slash confirmed → EventIngestionService calls + * confirmPenalty(intentId, slashAmount) → state = "confirmed", + * bondAmount reconciled downward. + * 2b. On-chain slash fails/never confirms → sweeper or event-ingestion + * service calls rollbackPenalty(intentId) → state = "failed", + * fillsFailed decremented so the solver is not permanently penalised + * for an unenforced slash. + */ + readonly pendingPenalties = new Map(); + constructor( @Inject(SOLVERS_REPOSITORY) private readonly repo: ISolversRepository, @@ -48,20 +72,6 @@ export class SolversService { return this.repo.save(updated); } - async markLive(address: string): Promise { - const solver = await this.repo.findByAddress(address); - if (!solver) return undefined; - const updated = { ...solver, isActive: true }; - return this.repo.save(updated); - } - - async markOffline(address: string): Promise { - const solver = await this.repo.findByAddress(address); - if (!solver) return undefined; - const updated = { ...solver, isActive: false }; - return this.repo.save(updated); - } - async deactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; @@ -72,21 +82,128 @@ 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); } /** * Records that a solver accepted an intent and then missed its fill - * deadline. Bumps the local fillsFailed counter for read paths (e.g. the - * leaderboard); the authoritative bond reduction happens on-chain via - * SolverRegistryService.slashSolver and should reconcile bondAmount here - * once event ingestion exists (see docs/architecture/onchain-settlement.md). + * deadline by entering a pending-slash state. + * + * Bumps the local fillsFailed counter immediately (optimistic increment) + * and stores a "pending" penalty entry so callers can later either confirm + * the penalty once the on-chain slash event arrives, or roll it back if the + * on-chain submission never confirms. + * + * The authoritative bond reduction happens on-chain via + * SolverRegistryService.slashSolver; bondAmount is reconciled in + * confirmPenalty() once the solver_slashed event is observed. */ - async recordFailedFill(address: string): Promise { + async recordFailedFill(address: string, intentId: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; const updated = { ...solver, fillsFailed: solver.fillsFailed + 1 }; - return this.repo.save(updated); + const saved = await this.repo.save(updated); + + // Track this as a pending penalty until on-chain confirmation. + this.pendingPenalties.set(intentId, { + intentId, + solverAddress: address, + detectedAt: Math.floor(Date.now() / 1000), + state: "pending", + }); + + return saved; + } + + /** + * Confirms a pending penalty once the solver_slashed on-chain event is + * ingested by EventIngestionService. + * + * Reconciles the solver's bondAmount downward by slashAmount and marks the + * penalty as "confirmed" so the in-memory record reflects the real on-chain + * balance. + * + * @param intentId The intent whose slash is now confirmed on-chain. + * @param slashAmount The amount slashed from the solver's bond (as a string, + * matching bondAmount's representation). + */ + async confirmPenalty(intentId: string, slashAmount: string): Promise { + const penalty = this.pendingPenalties.get(intentId); + if (!penalty || penalty.state !== "pending") { + this.logger.warn( + `confirmPenalty called for intentId=${intentId} but no pending penalty found (state=${penalty?.state ?? "none"})`, + ); + return null; + } + + const solver = await this.repo.findByAddress(penalty.solverAddress); + if (!solver) { + this.logger.error( + `confirmPenalty: solver ${penalty.solverAddress} not found when confirming slash for intent ${intentId}`, + ); + return null; + } + + // Reconcile bondAmount: clamp to 0 so we never go negative. + const current = BigInt(solver.bondAmount); + const slash = BigInt(slashAmount); + const newBond = current > slash ? current - slash : 0n; + + const updated = { ...solver, bondAmount: newBond.toString() }; + const saved = await this.repo.save(updated); + + this.pendingPenalties.set(intentId, { + ...penalty, + state: "confirmed", + confirmedSlashAmount: slashAmount, + }); + + this.logger.log( + `[penalty] confirmed: solver=${penalty.solverAddress} intent=${intentId} slashed=${slashAmount} newBond=${newBond}`, + ); + + return saved; + } + + /** + * Rolls back a pending penalty when the on-chain slash submission fails or + * is never confirmed. + * + * Decrements fillsFailed (reversing the optimistic increment from + * recordFailedFill) and marks the penalty as "failed" so operators can + * investigate the discrepancy. + * + * @param intentId The intent whose slash submission failed. + */ + async rollbackPenalty(intentId: string): Promise { + const penalty = this.pendingPenalties.get(intentId); + if (!penalty || penalty.state !== "pending") { + this.logger.warn( + `rollbackPenalty called for intentId=${intentId} but no pending penalty found (state=${penalty?.state ?? "none"})`, + ); + return null; + } + + const solver = await this.repo.findByAddress(penalty.solverAddress); + if (!solver) { + this.logger.error( + `rollbackPenalty: solver ${penalty.solverAddress} not found when rolling back penalty for intent ${intentId}`, + ); + return null; + } + + // Clamp at 0 to guard against double-rollback edge cases. + const newFailed = Math.max(0, solver.fillsFailed - 1); + const updated = { ...solver, fillsFailed: newFailed }; + const saved = await this.repo.save(updated); + + this.pendingPenalties.set(intentId, { ...penalty, state: "failed" }); + + this.logger.warn( + `[penalty] rolled back: solver=${penalty.solverAddress} intent=${intentId} (on-chain slash did not confirm)`, + ); + + return saved; } } diff --git a/src/solvers/solvers.types.ts b/src/solvers/solvers.types.ts index 32b4e88..18b7330 100644 --- a/src/solvers/solvers.types.ts +++ b/src/solvers/solvers.types.ts @@ -13,3 +13,31 @@ export interface SolverRecord { supportedChains: SupportedChain[]; supportedTokens: string[]; } + +/** + * Life-cycle of a single slash penalty. + * + * pending — sweeper detected a missed deadline; on-chain submission + * has been dispatched but not yet confirmed. + * confirmed — solver_slashed event observed on-chain; bondAmount has + * been reconciled to match the on-chain balance. + * failed — on-chain submission failed (network error / contract + * rejection); the fillsFailed increment is rolled back so + * the solver's local record does not permanently reflect a + * penalty that was never enforced on-chain. + */ +export type SolverPenaltyState = "pending" | "confirmed" | "failed"; + +/** A single pending-slash record keyed by intentId inside pendingPenalties. */ +export interface SolverPendingPenalty { + intentId: string; + solverAddress: string; + /** Unix timestamp (s) when the sweep detected the missed deadline. */ + detectedAt: number; + state: SolverPenaltyState; + /** + * On-chain confirmed slash amount in bond units, populated once a + * solver_slashed event is ingested and the penalty moves to "confirmed". + */ + confirmedSlashAmount?: string; +} diff --git a/src/soroban/event-ingestion.service.spec.ts b/src/soroban/event-ingestion.service.spec.ts index daed4bf..6f434a3 100644 --- a/src/soroban/event-ingestion.service.spec.ts +++ b/src/soroban/event-ingestion.service.spec.ts @@ -7,6 +7,13 @@ import { parseEventIndex, } from "./event-ingestion.service"; import { SorobanService } from "./soroban.service"; +import { SolversService } from "../solvers/solvers.service"; + +function fakeSolversService(): SolversService { + return { + confirmPenalty: jest.fn().mockResolvedValue(null), + } as unknown as SolversService; +} function makeIntentFilledEvent( overrides: Partial<{ ledger: number; id: string; intentId: string }> = {}, @@ -62,7 +69,7 @@ describe("EventIngestionService", () => { beforeEach(() => { sorobanService = {} as SorobanService; - service = new EventIngestionService(sorobanService, makeConfigService()); + service = new EventIngestionService(sorobanService, makeConfigService(), fakeSolversService()); }); it("processes a new event exactly once", () => { diff --git a/src/soroban/event-ingestion.service.ts b/src/soroban/event-ingestion.service.ts index 12e7896..32d79a5 100644 --- a/src/soroban/event-ingestion.service.ts +++ b/src/soroban/event-ingestion.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { scValToNative, SorobanRpc } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; import { SorobanService } from "./soroban.service"; +import { SolversService } from "../solvers/solvers.service"; const POLL_INTERVAL_MS = 10_000; @@ -40,6 +41,7 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { constructor( private readonly sorobanService: SorobanService, private readonly configService: ConfigService, + private readonly solversService: SolversService, ) {} onModuleInit() { @@ -113,10 +115,64 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { const eventName = typeof topic[0] === "string" ? topic[0] : undefined; if (eventName === "intent_filled") { this.handleIntentFilled(event, topic); + } else if (eventName === "solver_slashed") { + // Fire-and-forget: penalty confirmation is non-blocking relative to + // ingestion — a reconciliation failure is logged but never stalls the + // poll loop. + this.handleSolverSlashed(event, topic).catch((err) => + console.error( + `[event-ingestion] solver_slashed reconciliation failed at ledger=${event.ledger}: ${(err as Error).message}`, + ), + ); } } private handleIntentFilled(event: SorobanRpc.Api.EventResponse, topic: unknown[]): void { console.log(`[event-ingestion] intent_filled event at ledger=${event.ledger} txHash=${event.txHash}`, topic); } + + /** + * Handles a solver_slashed event emitted by the Soroban solver-registry + * contract once a slash transaction is confirmed on-chain. + * + * Expected topic layout (positions 1+ after the event name at position 0): + * topic[1] — solver address (string) + * topic[2] — intentId (string) + * topic[3] — slash amount (string or bigint) + * + * Calls SolversService.confirmPenalty() which reconciles bondAmount and + * marks the penalty as "confirmed" in the in-memory pendingPenalties map. + * + * If topic values cannot be extracted (malformed event), a warning is logged + * and the event is silently skipped — this protects against a bad contract + * event bringing down the ingestion loop. + */ + private async handleSolverSlashed( + event: SorobanRpc.Api.EventResponse, + topic: unknown[], + ): Promise { + const solverAddress = typeof topic[1] === "string" ? topic[1] : undefined; + const intentId = typeof topic[2] === "string" ? topic[2] : undefined; + const rawAmount = topic[3]; + const slashAmount = + typeof rawAmount === "bigint" + ? rawAmount.toString() + : typeof rawAmount === "string" + ? rawAmount + : undefined; + + if (!solverAddress || !intentId || !slashAmount) { + console.warn( + `[event-ingestion] solver_slashed event at ledger=${event.ledger} has unexpected topic shape; skipping reconciliation`, + { solverAddress, intentId, slashAmount, rawTopic: topic }, + ); + return; + } + + console.log( + `[event-ingestion] solver_slashed confirmed: solver=${solverAddress} intentId=${intentId} slashAmount=${slashAmount} ledger=${event.ledger}`, + ); + + await this.solversService.confirmPenalty(intentId, slashAmount); + } } diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 8b19079..f8f6a32 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -5,8 +5,10 @@ import { SorobanService } from "./soroban.service"; import { SolverRegistryService } from "./solver-registry.service"; import { SignerService } from "./signer.service"; import { StellarTxService } from "./stellar-tx.service"; +import { SolversModule } from "../solvers/solvers.module"; @Module({ + imports: [SolversModule], controllers: [SorobanController], providers: [ SorobanService, diff --git a/test/intent-lifecycle.e2e-spec.ts b/test/intent-lifecycle.e2e-spec.ts index 7989777..4bdf00d 100644 --- a/test/intent-lifecycle.e2e-spec.ts +++ b/test/intent-lifecycle.e2e-spec.ts @@ -11,6 +11,7 @@ import { INestApplication } from "@nestjs/common"; import request from "supertest"; import { createTestApp } from "./utils/create-test-app"; +import { MAX_OPEN_INTENTS_PER_USER } from "../src/intents/intents.service"; const BASE_INTENT = { user: "GLIFECYCLEE2ETEST12", @@ -195,4 +196,68 @@ describe("Intent lifecycle e2e (create → accept → fill)", () => { .send({ solver: "SOLVER_ALPHA" }) .expect(409); }); + + /** + * Per-user open-intent cap (issue: per-user open-intent cap) + * + * Creates exactly MAX_OPEN_INTENTS_PER_USER intents for a dedicated user, + * asserts the (N+1)th creation returns 409 with an explanatory message, then + * asserts that transitioning one existing intent out of open/accepted state + * (here: cancel) frees up the slot and allows creation to succeed again. + * + * NOTE: the seed data already occupies the store but belongs to different + * user addresses, so this test uses a unique address that starts at 0 open + * intents. + */ + it(`rejects the (MAX+1)th open intent for a user with 409 and succeeds again after one is cancelled`, async () => { + const CAP_USER = "GCAP_TEST_USER_E2E_01"; + const CAP_BASE = { + ...BASE_INTENT, + user: CAP_USER, + }; + + // Create exactly MAX_OPEN_INTENTS_PER_USER intents for this user. + // Use a low minDstAmount so the validation never blocks us. + const createdIds: string[] = []; + for (let i = 0; i < MAX_OPEN_INTENTS_PER_USER; i++) { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send(CAP_BASE) + .expect(201); + createdIds.push(res.body.intentId as string); + } + + expect(createdIds).toHaveLength(MAX_OPEN_INTENTS_PER_USER); + + // The (MAX_OPEN_INTENTS_PER_USER + 1)th attempt must fail with 409. + const capRes = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send(CAP_BASE) + .expect(409); + + // Error message must be distinct from the rate-limit 429 and explain the cap. + expect(capRes.body.message).toMatch(/cap reached/i); + expect(capRes.body.message).toMatch(String(MAX_OPEN_INTENTS_PER_USER)); + + // Cancel one existing intent to free up the slot. + const intentToCancel = createdIds[0]; + await request(app.getHttpServer()) + .post(`/api/v1/intents/${intentToCancel}/cancel`) + .send({ user: CAP_USER }) + .expect(201); + + // Verify the cancelled intent is no longer open. + const cancelledCheck = await request(app.getHttpServer()) + .get(`/api/v1/intents/${intentToCancel}`) + .expect(200); + expect(cancelledCheck.body.state).toBe("cancelled"); + + // Now creation must succeed again — the cap is user+state-scoped, not absolute. + const afterCancelRes = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send(CAP_BASE) + .expect(201); + expect(afterCancelRes.body.state).toBe("open"); + expect(afterCancelRes.body.user).toBe(CAP_USER); + }); });