From 95aa0bb4fd56fd638808e6b5bbed71a79b09dc76 Mon Sep 17 00:00:00 2001 From: sundayjob996E Date: Mon, 31 Aug 2026 10:48:44 +0100 Subject: [PATCH 1/2] sundayjob996E: add atomic cancelIfOpen/expireIfOpen/slashIfAccepted to fix cancel/expiry/slash race conditions IntentsController.cancel() used a check-then-update() sequence that could race with a concurrent accept() or sweeper expiry. IntentsSweeperService.sweep() had the same class of race for expiry (vs cancel/accept) and slashing (vs fill), both using the generic update() with no state guard. Adds cancelIfOpen/expireIfOpen/slashIfAccepted to IIntentsRepository, mirroring the existing acceptIfOpen/fillIfAccepted atomic-update pattern, implemented in both the in-memory and Prisma repositories, and wires the controller and sweeper to use them instead of read-check-update(). --- src/intents/intents-sweeper.service.ts | 11 +++-- src/intents/intents.controller.ts | 6 ++- src/intents/intents.repository.ts | 55 ++++++++++++++++++++++++ src/intents/intents.service.ts | 29 +++++++++++++ src/intents/prisma-intents.repository.ts | 51 ++++++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index f2ec0cd..18a31fd 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -37,7 +37,10 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { for (const intent of await this.intentsService.getByState("open")) { if (intent.deadline <= now) { - await this.intentsService.update(intent.intentId, { state: "expired" }); + // Atomic guard: a concurrent user cancel() or solver accept() may have + // already transitioned this intent out of "open" — skip it if so. + const expired = await this.intentsService.expireIfOpen(intent.intentId); + if (!expired) continue; // Audit trail (issue #62): system-driven expiration. this.intentsService.appendAuditEntry( intent.intentId, @@ -75,11 +78,13 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { ) { const reason = "accepted intent not filled before deadline"; - await this.intentsService.update(intentId, { - state: "slashed", + // Atomic guard: a concurrent solver fill() may have already transitioned + // this intent out of "accepted" — skip slashing if so. + const slashed = await this.intentsService.slashIfAccepted(intentId, { slashedAt: now, slashReason: reason, }); + if (!slashed) return; this.intentsGateway.broadcast({ type: "intent_slashed", intentId, solver, reason }); if (!solver) { diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index c072a02..8e17c9b 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -319,7 +319,11 @@ export class IntentsController { // Verify the user controls the claimed address verifyStellarSignature(dto.user, buildCancelMessage(id), dto.signature); - const updated = await this.intentsService.update(id, { state: "cancelled" }); + const updated = await this.intentsService.cancelIfOpen(id); + if (!updated) { + const current = await this.intentsService.get(id); + throw new ConflictException(`Cannot cancel intent in state: ${current?.state ?? "unknown"}`); + } // Audit trail (issue #217 / #62): record who cancelled and when. this.intentsService.appendAuditEntry(id, "cancelled", dto.user, "user cancelled"); diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index f445963..a39ed60 100644 --- a/src/intents/intents.repository.ts +++ b/src/intents/intents.repository.ts @@ -83,6 +83,34 @@ export interface IIntentsRepository { solver: string, patch: Omit, "state" | "solver">, ): Intent | null | Promise; + + /** + * Atomically transition an intent from `open` → `cancelled` only if it is + * currently in the `open` state. Mirrors the DB pattern: + * UPDATE intents SET state='cancelled' + * WHERE intent_id=$1 AND state='open' + * RETURNING * + * Returns the updated intent on success, `null` when the intent is not + * found or is not in the `open` state (e.g. already accepted or expired). + */ + cancelIfOpen(id: string): Intent | null | Promise; + + /** + * Atomically transition an intent from `open` → `expired` only if it is + * currently in the `open` state. Guards the sweeper's expiry pass against + * a concurrent user cancel() or solver accept() on the same intent. + */ + expireIfOpen(id: string): Intent | null | Promise; + + /** + * Atomically transition an intent from `accepted` → `slashed` only if it is + * currently in the `accepted` state. Guards the sweeper's slashing pass + * against a concurrent solver fill(). + */ + slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Intent | null | Promise; } /** @@ -150,6 +178,33 @@ export class InMemoryIntentsRepository implements IIntentsRepository { return updated; } + cancelIfOpen(id: string): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "open") return null; + const updated: Intent = { ...existing, state: "cancelled" }; + this.store.set(id, updated); + return updated; + } + + expireIfOpen(id: string): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "open") return null; + const updated: Intent = { ...existing, state: "expired" }; + this.store.set(id, updated); + return updated; + } + + slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "accepted") return null; + const updated: Intent = { ...existing, ...patch, state: "slashed" }; + this.store.set(id, updated); + return updated; + } + // ── seed ──────────────────────────────────────────────────────────────────── seed(): void { diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index e569332..98f3549 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -201,6 +201,35 @@ export class IntentsService implements OnModuleDestroy { return this.repo.fillIfAccepted(id, solver, patch); } + /** + * Atomically cancel an intent only if it is currently "open". + * Returns null when the intent is not found or is not in the "open" state + * (e.g. a concurrent accept() or sweeper expiry already transitioned it). + */ + async cancelIfOpen(id: string): Promise { + return this.repo.cancelIfOpen(id); + } + + /** + * Atomically expire an intent only if it is currently "open". + * Used by the sweeper so a concurrent user cancel() or solver accept() + * always wins the race. + */ + async expireIfOpen(id: string): Promise { + return this.repo.expireIfOpen(id); + } + + /** + * Atomically slash an intent only if it is currently "accepted". + * Used by the sweeper so a concurrent solver fill() always wins the race. + */ + async slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Promise { + return this.repo.slashIfAccepted(id, patch); + } + // --------------------------------------------------------------------------- // Audit trail (issue #217 / #62) // --------------------------------------------------------------------------- diff --git a/src/intents/prisma-intents.repository.ts b/src/intents/prisma-intents.repository.ts index bcaec0c..b6c9a4f 100644 --- a/src/intents/prisma-intents.repository.ts +++ b/src/intents/prisma-intents.repository.ts @@ -130,6 +130,57 @@ export class PrismaIntentsRepository implements IIntentsRepository { return row ? this.fromRow(row) : null; } + /** + * Atomically cancel an intent only when it is currently `open`. Guards + * against a concurrent solver accept() or sweeper expiry on the same intent. + */ + async cancelIfOpen(id: string): Promise { + const result = await this.prisma.intent.updateMany({ + where: { intentId: id, state: PrismaIntentState.open }, + data: { state: PrismaIntentState.cancelled }, + }); + + if (result.count === 0) return null; + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; + } + + /** + * Atomically expire an intent only when it is currently `open`. Used by the + * sweeper so a concurrent user cancel() or solver accept() always wins the race. + */ + async expireIfOpen(id: string): Promise { + const result = await this.prisma.intent.updateMany({ + where: { intentId: id, state: PrismaIntentState.open }, + data: { state: PrismaIntentState.expired }, + }); + + if (result.count === 0) return null; + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; + } + + /** + * Atomically slash an intent only when it is currently `accepted`. Used by + * the sweeper so a concurrent solver fill() always wins the race. + */ + async slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Promise { + const result = await this.prisma.intent.updateMany({ + where: { intentId: id, state: PrismaIntentState.accepted }, + data: { state: PrismaIntentState.slashed }, + }); + + if (result.count === 0) return null; + + const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); + return row ? this.fromRow(row) : null; + } + // ── Private helpers ──────────────────────────────────────────────────────── /** Map Intent → Prisma create/update data (omits intentId which is the key). */ From 05b2967b427dcf2798f851bcf959ce1598b02fb1 Mon Sep 17 00:00:00 2001 From: sundayjob996E Date: Mon, 31 Aug 2026 10:51:26 +0100 Subject: [PATCH 2/2] sundayjob996E: add lastActiveAt field to SolverRecord and Solver schema registeredAt was the only timestamp on a solver record, making it impossible to distinguish a solver that registered once and has been dormant since from one that's continuously active. Adds lastActiveAt (Unix epoch seconds) to SolverRecord and the Solver Prisma model, updated on registration, successful fill, and any deactivate/reactivate/deregister/markLive/markOffline call. Wires IntentsController.fill() to bump it via a new SolversService.recordSuccessfulFill(). Data model and write-path only, per issue scope. --- .../migration.sql | 9 +++++++ prisma/schema.prisma | 2 ++ src/intents/intents.controller.ts | 2 ++ src/solvers/prisma-solvers.repository.ts | 3 +++ src/solvers/solvers.seed.ts | 3 +++ src/solvers/solvers.service.ts | 27 ++++++++++++++----- src/solvers/solvers.types.ts | 2 ++ 7 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260831000000_solver_last_active_at/migration.sql diff --git a/prisma/migrations/20260831000000_solver_last_active_at/migration.sql b/prisma/migrations/20260831000000_solver_last_active_at/migration.sql new file mode 100644 index 0000000..bab8eb3 --- /dev/null +++ b/prisma/migrations/20260831000000_solver_last_active_at/migration.sql @@ -0,0 +1,9 @@ +-- Migration: add solvers.last_active_at (issue #56) +-- Tracks the most recent activity (registration, fill, or status change) +-- for a solver. Backfilled from registered_at for existing rows. + +ALTER TABLE "solvers" ADD COLUMN "last_active_at" INTEGER; + +UPDATE "solvers" SET "last_active_at" = "registered_at" WHERE "last_active_at" IS NULL; + +ALTER TABLE "solvers" ALTER COLUMN "last_active_at" SET NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0247b88..fa32cad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,6 +94,8 @@ model Solver { isActive Boolean @default(true) @map("is_active") /// Unix epoch seconds. registeredAt Int @map("registered_at") + /// Unix epoch seconds of the solver's most recent activity (registration, fill, or status change). + lastActiveAt Int @map("last_active_at") /// Chains this solver supports (stored as JSON array of SupportedChain values). supportedChains Json @map("supported_chains") /// Token symbols this solver can handle. diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 8e17c9b..b89cbca 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -293,6 +293,8 @@ export class IntentsController { throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot fill`); } + await this.solversService.recordSuccessfulFill(dto.solver); + this.intentsGateway.broadcast({ type: "intent_filled", intentId: id, diff --git a/src/solvers/prisma-solvers.repository.ts b/src/solvers/prisma-solvers.repository.ts index 06b458f..741a362 100644 --- a/src/solvers/prisma-solvers.repository.ts +++ b/src/solvers/prisma-solvers.repository.ts @@ -58,6 +58,7 @@ export class PrismaSolversRepository implements ISolversRepository { avgFillTime: solver.avgFillTime, isActive: solver.isActive, registeredAt: solver.registeredAt, + lastActiveAt: solver.lastActiveAt, supportedChains: solver.supportedChains as unknown as Prisma.InputJsonValue, supportedTokens: solver.supportedTokens as unknown as Prisma.InputJsonValue, }; @@ -73,6 +74,7 @@ export class PrismaSolversRepository implements ISolversRepository { avgFillTime: number; isActive: boolean; registeredAt: number; + lastActiveAt: number; supportedChains: Prisma.JsonValue; supportedTokens: Prisma.JsonValue; }): SolverRecord { @@ -86,6 +88,7 @@ export class PrismaSolversRepository implements ISolversRepository { avgFillTime: row.avgFillTime, isActive: row.isActive, registeredAt: row.registeredAt, + lastActiveAt: row.lastActiveAt, supportedChains: row.supportedChains as SolverRecord["supportedChains"], supportedTokens: row.supportedTokens as SolverRecord["supportedTokens"], }; diff --git a/src/solvers/solvers.seed.ts b/src/solvers/solvers.seed.ts index b97f684..467ef63 100644 --- a/src/solvers/solvers.seed.ts +++ b/src/solvers/solvers.seed.ts @@ -33,6 +33,7 @@ export function buildSeedSolvers(): SolverRecord[] { avgFillTime: 47, isActive: true, registeredAt: now - 86400 * 30, + lastActiveAt: now - 3600, supportedChains: ["ethereum", "base", "arbitrum", "optimism"], supportedTokens: ["USDC", "WETH", "WBTC"], }, @@ -46,6 +47,7 @@ export function buildSeedSolvers(): SolverRecord[] { avgFillTime: 32, isActive: true, registeredAt: now - 86400 * 45, + lastActiveAt: now - 1800, supportedChains: ["ethereum", "base", "polygon", "arbitrum", "optimism", "avalanche"], supportedTokens: ["USDC", "WETH", "WBTC", "MATIC", "AVAX"], }, @@ -59,6 +61,7 @@ export function buildSeedSolvers(): SolverRecord[] { avgFillTime: 89, isActive: true, registeredAt: now - 86400 * 7, + lastActiveAt: now - 7200, supportedChains: ["ethereum", "polygon"], supportedTokens: ["USDC", "WETH"], }, diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..2994756 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -28,15 +28,17 @@ export class SolversService { async register( data: Omit< SolverRecord, - "registeredAt" | "fillsCompleted" | "fillsFailed" | "totalVolume" + "registeredAt" | "lastActiveAt" | "fillsCompleted" | "fillsFailed" | "totalVolume" >, ): Promise { + const now = Math.floor(Date.now() / 1000); const solver: SolverRecord = { ...data, fillsCompleted: 0, fillsFailed: 0, totalVolume: "0", - registeredAt: Math.floor(Date.now() / 1000), + registeredAt: now, + lastActiveAt: now, }; return this.repo.save(solver); } @@ -44,35 +46,46 @@ export class SolversService { async deregister(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return undefined; - const updated = { ...solver, isActive: false }; + const updated = { ...solver, isActive: false, lastActiveAt: Math.floor(Date.now() / 1000) }; 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 }; + const updated = { ...solver, isActive: true, lastActiveAt: Math.floor(Date.now() / 1000) }; 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 }; + const updated = { ...solver, isActive: false, lastActiveAt: Math.floor(Date.now() / 1000) }; return this.repo.save(updated); } async deactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive: false }; + const updated = { ...solver, isActive: false, lastActiveAt: Math.floor(Date.now() / 1000) }; return this.repo.save(updated); } async reactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive }; + const updated = { ...solver, isActive, lastActiveAt: Math.floor(Date.now() / 1000) }; + return this.repo.save(updated); + } + + /** + * Bumps lastActiveAt on a successful fill. Called by IntentsController.fill() + * after fillIfAccepted() succeeds. + */ + async recordSuccessfulFill(address: string): Promise { + const solver = await this.repo.findByAddress(address); + if (!solver) return null; + const updated = { ...solver, lastActiveAt: Math.floor(Date.now() / 1000) }; return this.repo.save(updated); } diff --git a/src/solvers/solvers.types.ts b/src/solvers/solvers.types.ts index 32b4e88..a2df17c 100644 --- a/src/solvers/solvers.types.ts +++ b/src/solvers/solvers.types.ts @@ -10,6 +10,8 @@ export interface SolverRecord { avgFillTime: number; // seconds isActive: boolean; registeredAt: number; + /** Unix epoch seconds of the solver's most recent activity (registration, fill, or status change). */ + lastActiveAt: number; supportedChains: SupportedChain[]; supportedTokens: string[]; }