diff --git a/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index d5dafa7..6bca631 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 093700a..53fa706 100644 --- a/docs/solver-onboarding.md +++ b/docs/solver-onboarding.md @@ -214,3 +214,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 736ccd6..2d33314 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.controller.ts b/src/intents/intents.controller.ts index 3d0d117..99e3554 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -29,6 +29,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"; @@ -203,6 +204,9 @@ 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); 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 d86856e..cf03885 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -11,7 +11,12 @@ 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"; import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; @@ -22,6 +27,23 @@ const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slash /** How long a completed idempotency-key result stays replayable. */ const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours +/** + * 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. * @@ -277,6 +299,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); } @@ -285,11 +323,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 a3b4d21..7ba9358 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from "@nestjs/common"; import { SupportedChain } from "../intents/intents.types"; import { SOLVERS_REPOSITORY, ISolversRepository } from "./solvers.repository"; -import { SolverRecord } from "./solvers.types"; +import { SolverRecord, SolverPendingPenalty } from "./solvers.types"; export type LeaderboardWindow = "24h" | "7d" | "30d" | "all"; @@ -102,16 +102,123 @@ export class SolversService { /** * 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; } async recordSlash( diff --git a/src/solvers/solvers.types.ts b/src/solvers/solvers.types.ts index a2df17c..c5e43eb 100644 --- a/src/solvers/solvers.types.ts +++ b/src/solvers/solvers.types.ts @@ -15,3 +15,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 594f627..c5eec6e 100644 --- a/src/soroban/event-ingestion.service.ts +++ b/src/soroban/event-ingestion.service.ts @@ -4,6 +4,7 @@ import { scValToNative, SorobanRpc } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; import { logger } from "../common/logger"; import { SorobanService } from "./soroban.service"; +import { SolversService } from "../solvers/solvers.service"; const POLL_INTERVAL_MS = 10_000; const RECONCILE_INTERVAL_MS = 60_000; @@ -45,6 +46,7 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { constructor( private readonly sorobanService: SorobanService, private readonly configService: ConfigService, + private readonly solversService: SolversService, ) {} onModuleInit() { @@ -127,6 +129,15 @@ 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}`, + ), + ); } const intentId = typeof topic[1] === "string" ? topic[1] : undefined; @@ -162,4 +173,49 @@ export class EventIngestionService implements OnModuleInit, OnModuleDestroy { this.lastIntentUpdateById.set(intentId, Math.floor(Date.now() / 1000)); } } + + /** + * 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 49d6e97..5d1c602 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -5,6 +5,7 @@ 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: [forwardRef(() => IntentsModule)], 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); + }); });