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 870e248..0edef3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,6 +96,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-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index 926e261..baa90a7 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -46,7 +46,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, @@ -117,11 +120,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 58cc33a..1dbb9ef 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -342,6 +342,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, @@ -368,7 +370,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 e2518be..136399d 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -268,6 +268,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 3d4bfca..2713131 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). */ 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 ce1201e..d03250a 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -49,15 +49,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); } @@ -65,28 +67,28 @@ 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); } 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[]; }