Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions src/intents/intents-sweeper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
Expand Down
55 changes: 55 additions & 0 deletions src/intents/intents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,34 @@ export interface IIntentsRepository {
solver: string,
patch: Omit<Partial<Intent>, "state" | "solver">,
): Intent | null | Promise<Intent | null>;

/**
* 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<Intent | null>;

/**
* 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<Intent | null>;

/**
* 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<Intent | null>;
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions src/intents/intents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Intent | null> {
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<Intent | null> {
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<Intent | null> {
return this.repo.slashIfAccepted(id, patch);
}

// ---------------------------------------------------------------------------
// Audit trail (issue #217 / #62)
// ---------------------------------------------------------------------------
Expand Down
51 changes: 51 additions & 0 deletions src/intents/prisma-intents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Intent | null> {
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<Intent | null> {
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<Intent | null> {
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). */
Expand Down
3 changes: 3 additions & 0 deletions src/solvers/prisma-solvers.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -73,6 +74,7 @@ export class PrismaSolversRepository implements ISolversRepository {
avgFillTime: number;
isActive: boolean;
registeredAt: number;
lastActiveAt: number;
supportedChains: Prisma.JsonValue;
supportedTokens: Prisma.JsonValue;
}): SolverRecord {
Expand All @@ -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"],
};
Expand Down
3 changes: 3 additions & 0 deletions src/solvers/solvers.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
Expand All @@ -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"],
},
Expand All @@ -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"],
},
Expand Down
14 changes: 8 additions & 6 deletions src/solvers/solvers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,44 +49,46 @@ export class SolversService {
async register(
data: Omit<
SolverRecord,
"registeredAt" | "fillsCompleted" | "fillsFailed" | "totalVolume"
"registeredAt" | "lastActiveAt" | "fillsCompleted" | "fillsFailed" | "totalVolume"
>,
): Promise<SolverRecord> {
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);
}

async deregister(address: string): Promise<SolverRecord | undefined> {
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<SolverRecord | undefined> {
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<SolverRecord | undefined> {
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<SolverRecord | null> {
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);
}

Expand Down
2 changes: 2 additions & 0 deletions src/solvers/solvers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}