From d3a45204eecfd7eda0fb7445240b84f9970d1fd3 Mon Sep 17 00:00:00 2001 From: only1dreamgene Date: Sat, 24 Oct 2026 07:58:12 -0700 Subject: [PATCH] Add bonded dispute workflow for payout decisions (#207) - Migration 007: claim_disputes (status, bond state, window, reversal link) and settlement_reversals (one owed reconciliation per decision). - DisputeService: open/transition/get with explicit state machine, DB-clock challenge window per coverage type (DISPUTE_WINDOW_HOURS), duplicate and final-decision guards, in-flight settlement guard, row locks. - DisputeController: POST /api/v1/disputes, GET /api/v1/disputes/:id, POST /api/v1/disputes/:id/transitions; ops-authenticated. - trust.dispute events for every lifecycle step; refract_claim_disputes_open gauge. Co-Authored-By: Claude Haiku 5.5 --- docs/trust-events.md | 2 +- src/claim/claim.module.ts | 3 +- src/claim/dispute/dispute.controller.ts | 74 +++++ src/claim/dispute/dispute.module.ts | 10 + src/claim/dispute/dispute.service.ts | 332 +++++++++++++++++++++++ src/db/migrations/007_claim_disputes.sql | 62 +++++ src/metrics/metrics.service.ts | 10 + src/oracle/trust-events.ts | 2 +- 8 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 src/claim/dispute/dispute.controller.ts create mode 100644 src/claim/dispute/dispute.module.ts create mode 100644 src/claim/dispute/dispute.service.ts create mode 100644 src/db/migrations/007_claim_disputes.sql diff --git a/docs/trust-events.md b/docs/trust-events.md index dde2aaf..803eb5f 100644 --- a/docs/trust-events.md +++ b/docs/trust-events.md @@ -32,7 +32,7 @@ Delivery is at-least-once; clients must dedupe by `id`. Breaking changes bump | `trust.feed` | `kind` (`divergence`/`quorum_failure`/`confidence_change`), `feedId`, `coverageType?`, `confidence?` (0-1), `degraded` | | `trust.coverage` | `coverageType`, `degraded`, `confidence?` | | `trust.pause` | `kind` (`paused`/`resumed`), `coverageType?`, `reason?` | -| `trust.dispute` | `kind` (`opened`), `coverageType?`, `disputeId` | +| `trust.dispute` | `kind` (`opened`/`under_review`/`upheld`/`reversed`/`withdrawn`), `coverageType?`, `disputeId` | ## Authentication and redaction diff --git a/src/claim/claim.module.ts b/src/claim/claim.module.ts index c9284d6..f803956 100644 --- a/src/claim/claim.module.ts +++ b/src/claim/claim.module.ts @@ -7,10 +7,11 @@ import { ClaimRepository } from "./claim.repository"; import { ClaimScheduler } from "./claim.scheduler"; import { ClaimSettlementService } from "./claim-settlement.service"; import { ClaimService } from "./claim.service"; +import { DisputeModule } from "./dispute/dispute.module"; import { ReconciliationService } from "./reconciliation.service"; @Module({ - imports: [PolicyModule, OracleModule, ConsensusModule], + imports: [PolicyModule, OracleModule, ConsensusModule, DisputeModule], controllers: [ClaimController], providers: [ ClaimRepository, diff --git a/src/claim/dispute/dispute.controller.ts b/src/claim/dispute/dispute.controller.ts new file mode 100644 index 0000000..4094f20 --- /dev/null +++ b/src/claim/dispute/dispute.controller.ts @@ -0,0 +1,74 @@ +import { BadRequestException, Body, Controller, Get, Param, Post, Req, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Request } from "express"; +import { OpsAuthGuard } from "../../common/guards/ops-auth.guard"; +import { DisputeService, TransitionDisputeInput } from "./dispute.service"; + +type OpsRequest = Request & { apiKey?: { name?: string } }; + +const requireString = (value: unknown, field: string, max: number): string => { + if (typeof value !== "string" || value.trim() === "" || value.length > max) { + throw new BadRequestException(`${field} is required (max ${max} characters)`); + } + return value.trim(); +}; + +const optionalString = (value: unknown, field: string, max: number): string | undefined => + value === undefined || value === null ? undefined : requireString(value, field, max); + +const RESOLUTIONS = ["under_review", "upheld", "reversed", "withdrawn"] as const; + +/** + * Bonded challenge API for payout decisions. Every route is ops-authenticated: + * a bond must be posted by a known operator before a dispute is recorded, so + * anonymous callers cannot open disputes against bonds they never posted. + * Operator-scoped auth and on-chain bond verification are follow-ups. + */ +@ApiTags("disputes") +@Controller("api/v1/disputes") +@UseGuards(OpsAuthGuard) +export class DisputeController { + constructor(private readonly disputes: DisputeService) {} + + @ApiOperation({ summary: "Open a bonded challenge against a payout decision inside its window" }) + @Post() + open(@Req() req: OpsRequest, @Body() body: { decisionId?: unknown; reason?: unknown; bondReference?: unknown }) { + return this.disputes.open({ + decisionId: requireString(body?.decisionId, "decisionId", 256), + reason: requireString(body?.reason, "reason", 2000), + bondReference: requireString(body?.bondReference, "bondReference", 256), + actor: req.apiKey?.name ?? "ops", + }); + } + + @ApiOperation({ summary: "Get a dispute's status, bond state and reversal link" }) + @Get(":id") + get(@Param("id") id: string) { + return this.disputes.get(id); + } + + @ApiOperation({ + summary: "Move a dispute to under_review, upheld, reversed or withdrawn", + description: + "Reversal requires replacementDecisionId: the recomputed decision that supersedes the disputed one. " + + "The original settlement and evidence are not modified.", + }) + @Post(":id/transitions") + transition( + @Req() req: OpsRequest, + @Param("id") id: string, + @Body() body: { to?: unknown; note?: unknown; replacementDecisionId?: unknown }, + ) { + const to = body?.to; + if (typeof to !== "string" || !(RESOLUTIONS as readonly string[]).includes(to)) { + throw new BadRequestException(`to must be one of ${RESOLUTIONS.join(", ")}`); + } + const input: TransitionDisputeInput = { + to: to as TransitionDisputeInput["to"], + note: optionalString(body.note, "note", 2000), + replacementDecisionId: optionalString(body.replacementDecisionId, "replacementDecisionId", 256), + actor: req.apiKey?.name ?? "ops", + }; + return this.disputes.transition(id, input); + } +} diff --git a/src/claim/dispute/dispute.module.ts b/src/claim/dispute/dispute.module.ts new file mode 100644 index 0000000..7a8a5b9 --- /dev/null +++ b/src/claim/dispute/dispute.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { DisputeController } from "./dispute.controller"; +import { DisputeService } from "./dispute.service"; + +@Module({ + controllers: [DisputeController], + providers: [DisputeService], + exports: [DisputeService], +}) +export class DisputeModule {} diff --git a/src/claim/dispute/dispute.service.ts b/src/claim/dispute/dispute.service.ts new file mode 100644 index 0000000..8945823 --- /dev/null +++ b/src/claim/dispute/dispute.service.ts @@ -0,0 +1,332 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + OnApplicationBootstrap, + Optional, +} from "@nestjs/common"; +import type { PoolClient } from "pg"; +import { DatabaseService } from "../../db/database.service"; +import { MetricsService } from "../../metrics/metrics.service"; +import { TrustEventsService } from "../../oracle/trust-events.service"; +import type { TrustEventInput } from "../../oracle/trust-events"; + +export const DISPUTE_STATUSES = ["open", "under_review", "upheld", "reversed", "withdrawn"] as const; +export type DisputeStatus = (typeof DISPUTE_STATUSES)[number]; + +type BondStatus = "posted" | "forfeited" | "returned"; + +/** Allowed moves. Upheld, reversed and withdrawn are final. */ +const TRANSITIONS: Record = { + open: ["under_review", "withdrawn"], + under_review: ["upheld", "reversed", "withdrawn"], + upheld: [], + reversed: [], + withdrawn: [], +}; + +const BOND_ON_RESOLUTION: Partial>> = { + upheld: "forfeited", + reversed: "returned", + withdrawn: "returned", +}; + +const DEFAULT_WINDOW_HOURS = 72; +const UNIQUE_VIOLATION = "23505"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export interface DisputeRecord { + id: string; + decisionId: string; + policyId: string; + coverageType: string; + status: DisputeStatus; + reason: string; + bondReference: string; + bondStatus: BondStatus; + openedBy: string; + windowClosesAt: Date; + resolutionNote: string | null; + replacementDecisionId: string | null; + reversal: { amount: string; status: string } | null; + createdAt: Date; + resolvedAt: Date | null; +} + +interface DisputeRow { + id: string; + decisionId: string; + policyId: string; + coverageType: string; + status: DisputeStatus; + reason: string; + bondReference: string; + bondStatus: BondStatus; + openedBy: string; + windowClosesAt: Date; + resolutionNote: string | null; + replacementDecisionId: string | null; + reversalAmount: string | null; + reversalStatus: string | null; + createdAt: Date; + resolvedAt: Date | null; +} + +export interface OpenDisputeInput { + decisionId: string; + reason: string; + bondReference: string; + actor: string; +} + +export interface TransitionDisputeInput { + to: Exclude; + note?: string; + replacementDecisionId?: string; + actor: string; +} + +const SELECT_DISPUTE = ` + SELECT d.id, d.decision_id AS "decisionId", d.policy_id AS "policyId", + p.coverage_type::text AS "coverageType", d.status, d.reason, + d.bond_reference AS "bondReference", d.bond_status AS "bondStatus", + d.opened_by AS "openedBy", d.window_closes_at AS "windowClosesAt", + d.resolution_note AS "resolutionNote", + d.replacement_decision_id AS "replacementDecisionId", + r.amount::text AS "reversalAmount", r.status AS "reversalStatus", + d.created_at AS "createdAt", d.resolved_at AS "resolvedAt" + FROM claim_disputes d + JOIN policies p ON p.id = d.policy_id + LEFT JOIN settlement_reversals r ON r.dispute_id = d.id`; + +/** Window per coverage type, in hours. Env: JSON object, e.g. {"default":72,"stablecoin_depeg":24}. */ +export function parseDisputeWindows(raw: string | undefined): Record { + if (!raw?.trim()) return {}; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("DISPUTE_WINDOW_HOURS must be a JSON object of positive hour values"); + } + const windows: Record = {}; + for (const [coverage, hours] of Object.entries(parsed)) { + if (typeof hours !== "number" || !Number.isFinite(hours) || hours <= 0) { + throw new Error(`DISPUTE_WINDOW_HOURS.${coverage} must be a positive number of hours`); + } + windows[coverage] = hours; + } + return windows; +} + +/** + * Bonded challenge workflow for payout-triggering decisions. + * + * Timing is judged by the database clock (`now()` and the settlement's + * `created_at`), never by the caller's clock. A decision is final once its + * window has elapsed or a dispute on it has resolved. + * + * Bond posting is recorded, not enforced on-chain; the on-chain escrow is out of scope. + */ +@Injectable() +export class DisputeService implements OnApplicationBootstrap { + private readonly logger = new Logger(DisputeService.name); + private readonly windows = parseDisputeWindows(process.env.DISPUTE_WINDOW_HOURS); + + constructor( + private readonly db: DatabaseService, + private readonly metrics: MetricsService, + @Optional() private readonly trust?: TrustEventsService, + ) {} + + /** Seeds the gauge from the database so a restart does not report zero open disputes. */ + onApplicationBootstrap(): Promise { + return this.refreshOpenGauge(); + } + + windowHoursFor(coverageType: string): number { + return this.windows[coverageType] ?? this.windows.default ?? DEFAULT_WINDOW_HOURS; + } + + async open(input: OpenDisputeInput): Promise { + const disputeId = await this.inTransaction(async (client) => { + const settlement = ( + await client.query<{ status: string; policyId: string; coverageType: string }>( + `SELECT cs.status, cs.policy_id AS "policyId", p.coverage_type::text AS "coverageType" + FROM claim_settlements cs + JOIN policies p ON p.id = cs.policy_id + WHERE cs.decision_id = $1 + FOR UPDATE OF cs`, + [input.decisionId] + ) + ).rows[0]; + if (!settlement) throw new NotFoundException(`Unknown decision ${input.decisionId}`); + if (settlement.status === "pending" || settlement.status === "submitted") { + throw new ConflictException(`Settlement for ${input.decisionId} is in flight; retry after reconciliation`); + } + if (settlement.status !== "confirmed") { + throw new ConflictException(`Decision ${input.decisionId} did not pay out (settlement ${settlement.status})`); + } + + const windowSeconds = this.windowHoursFor(settlement.coverageType) * 3600; + const timing = ( + await client.query<{ closed: boolean }>( + `SELECT now() >= created_at + make_interval(secs => $2) AS closed + FROM claim_settlements WHERE decision_id = $1`, + [input.decisionId, windowSeconds] + ) + ).rows[0]; + if (timing.closed) throw new ConflictException(`Challenge window for ${input.decisionId} has closed`); + + try { + const inserted = await client.query<{ id: string }>( + `INSERT INTO claim_disputes (decision_id, policy_id, reason, bond_reference, opened_by, window_closes_at) + VALUES ($1, $2, $3, $4, $5, + (SELECT created_at + make_interval(secs => $6) FROM claim_settlements WHERE decision_id = $1)) + RETURNING id`, + [input.decisionId, settlement.policyId, input.reason, input.bondReference, input.actor, windowSeconds] + ); + return inserted.rows[0].id; + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + throw new ConflictException(`Decision ${input.decisionId} already has a live dispute or is final`); + } + throw err; + } + }); + + const record = await this.get(disputeId); + await this.afterChange(record, "opened", input.actor); + return record; + } + + async transition(id: string, input: TransitionDisputeInput): Promise { + this.assertUuid(id); + await this.inTransaction(async (client) => { + const row = ( + await client.query<{ status: DisputeStatus; decisionId: string; policyId: string; settlementStatus: string; amount: string }>( + `SELECT d.status, d.decision_id AS "decisionId", d.policy_id AS "policyId", + cs.status AS "settlementStatus", cs.amount::text AS amount + FROM claim_disputes d + JOIN claim_settlements cs ON cs.decision_id = d.decision_id + WHERE d.id = $1 + FOR UPDATE OF d, cs`, + [id] + ) + ).rows[0]; + if (!row) throw new NotFoundException(`Unknown dispute ${id}`); + if (!TRANSITIONS[row.status].includes(input.to)) { + throw new ConflictException(`Cannot move dispute from ${row.status} to ${input.to}`); + } + + if (input.to === "reversed") { + if (!input.replacementDecisionId?.trim()) { + throw new BadRequestException("replacementDecisionId is required to reverse a decision"); + } + if (row.settlementStatus !== "confirmed") { + throw new ConflictException(`Settlement for ${row.decisionId} is ${row.settlementStatus}; cannot reverse`); + } + // The original payout stays confirmed; the owed reconciliation is booked once per decision. + await client.query( + `INSERT INTO settlement_reversals (dispute_id, decision_id, policy_id, amount) + VALUES ($1, $2, $3, $4)`, + [id, row.decisionId, row.policyId, row.amount] + ); + } + + const updated = await client.query( + `UPDATE claim_disputes + SET status = $2, + resolution_note = COALESCE($3, resolution_note), + replacement_decision_id = COALESCE($4, replacement_decision_id), + bond_status = COALESCE($5, bond_status), + resolved_at = CASE WHEN $6 THEN now() ELSE resolved_at END, + updated_at = now() + WHERE id = $1 AND status = $7`, + [ + id, + input.to, + input.note ?? null, + input.to === "reversed" ? input.replacementDecisionId!.trim() : null, + BOND_ON_RESOLUTION[input.to] ?? null, + input.to !== "under_review", + row.status, + ] + ); + if (updated.rowCount === 0) throw new ConflictException(`Dispute ${id} changed concurrently; retry`); + }).catch((err) => { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + throw new ConflictException("Decision already reversed or replacement decision already in use"); + } + throw err; + }); + + const record = await this.get(id); + await this.afterChange(record, input.to, input.actor); + return record; + } + + async get(id: string): Promise { + this.assertUuid(id); + const row = (await this.db.query(`${SELECT_DISPUTE} WHERE d.id = $1`, [id])).rows[0]; + if (!row) throw new NotFoundException(`Unknown dispute ${id}`); + return toRecord(row); + } + + /** Refreshes `refract_claim_disputes_open`. Failures are logged, never thrown into the request path. */ + async refreshOpenGauge(): Promise { + try { + const { rows } = await this.db.query<{ count: string }>( + `SELECT count(*)::text AS count FROM claim_disputes WHERE status IN ('open', 'under_review')` + ); + this.metrics.recordOpenDisputes(Number(rows[0].count)); + } catch (err) { + this.logger.warn(`Open-dispute gauge not refreshed: ${(err as Error).message}`); + } + } + + private async afterChange( + record: DisputeRecord, + kind: "opened" | Exclude, + actor: string + ): Promise { + await this.refreshOpenGauge(); + if (!this.trust) return; + const event: TrustEventInput = { + type: "trust.dispute", + payload: { kind, coverageType: record.coverageType, disputeId: record.id }, + internal: { actor }, + }; + try { + await this.trust.publish(event); + } catch (err) { + this.logger.warn(`Dispute event ${kind} not published: ${(err as Error).message}`); + } + } + + private async inTransaction(fn: (client: PoolClient) => Promise): Promise { + const client = await this.db.getClient(); + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (err) { + await client.query("ROLLBACK").catch(() => undefined); + throw err; + } finally { + client.release(); + } + } + + private assertUuid(id: string): void { + if (!UUID.test(id)) throw new NotFoundException(`Unknown dispute ${id}`); + } +} + +function toRecord(row: DisputeRow): DisputeRecord { + const { reversalAmount, reversalStatus, ...dispute } = row; + return { + ...dispute, + reversal: reversalAmount === null ? null : { amount: reversalAmount, status: reversalStatus ?? "owed" }, + }; +} diff --git a/src/db/migrations/007_claim_disputes.sql b/src/db/migrations/007_claim_disputes.sql new file mode 100644 index 0000000..193894a --- /dev/null +++ b/src/db/migrations/007_claim_disputes.sql @@ -0,0 +1,62 @@ +-- Migration: bonded dispute and challenge workflow for payout decisions (issue #207) +-- +-- A dispute challenges a confirmed settlement (claim_settlements.decision_id) +-- inside a bounded window that starts when the settlement was created, measured +-- on the database clock. Status moves: +-- open → under_review → upheld | reversed +-- open | under_review → withdrawn +-- +-- Bond accounting is recorded here and is contract-ready: posted at open, +-- forfeited on upheld, returned on reversed or withdrawn. +-- +-- At most one non-withdrawn dispute may exist per decision. That rejects +-- duplicate challenges and any challenge against an already-resolved decision. +-- +-- Reversal never edits the original settlement or its evidence. It links a +-- recomputed replacement decision (replacement_decision_id, which supersedes +-- decision_id) and books exactly one settlement_reversals row per decision, +-- so the compensating reconciliation cannot be recorded twice. + +BEGIN; + +CREATE TABLE IF NOT EXISTS claim_disputes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + decision_id TEXT NOT NULL REFERENCES claim_settlements(decision_id), + policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'open' CHECK ( + status IN ('open', 'under_review', 'upheld', 'reversed', 'withdrawn') + ), + reason TEXT NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 2000), + bond_reference TEXT NOT NULL, + bond_status TEXT NOT NULL DEFAULT 'posted' CHECK ( + bond_status IN ('posted', 'forfeited', 'returned') + ), + opened_by TEXT NOT NULL, + window_closes_at TIMESTAMPTZ NOT NULL, + resolution_note TEXT, + replacement_decision_id TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + CHECK (replacement_decision_id IS NULL OR replacement_decision_id <> decision_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_disputes_live_decision + ON claim_disputes(decision_id) + WHERE status <> 'withdrawn'; + +CREATE INDEX IF NOT EXISTS idx_claim_disputes_open + ON claim_disputes(status) + WHERE status IN ('open', 'under_review'); + +CREATE TABLE IF NOT EXISTS settlement_reversals ( + dispute_id UUID PRIMARY KEY REFERENCES claim_disputes(id), + decision_id TEXT NOT NULL UNIQUE REFERENCES claim_settlements(decision_id), + policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE, + amount NUMERIC(38, 18) NOT NULL CHECK (amount > 0), + status TEXT NOT NULL DEFAULT 'owed' CHECK (status IN ('owed', 'reconciled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMIT; diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index e74fe42..acbf222 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -77,6 +77,12 @@ export class MetricsService implements RelayerMetricsSink { registers: [this.registry], }); + private readonly openDisputes = new Gauge({ + name: "refract_claim_disputes_open", + help: "Claim disputes currently open or under review.", + registers: [this.registry], + }); + private readonly relayerBalance = new Gauge({ name: "refract_relayer_balance_stroops", help: "Relayer native balance in stroops.", @@ -161,6 +167,10 @@ export class MetricsService implements RelayerMetricsSink { this.oracleDeadlineOverrun.labels(labels).observe(Math.max(0, overrunSeconds)); } + recordOpenDisputes(count: number): void { + this.openDisputes.set(count); + } + recordRelayerBalance(a: BalanceAssessment, spend: FeeSpendSummary): void { this.relayerBalance.set(Number(a.balanceStroops)); this.relayerSpendable.set(Number(a.spendableStroops)); diff --git a/src/oracle/trust-events.ts b/src/oracle/trust-events.ts index 9c3d590..fb782bd 100644 --- a/src/oracle/trust-events.ts +++ b/src/oracle/trust-events.ts @@ -45,7 +45,7 @@ const PUBLIC_PAYLOADS = { reason: z.string().max(256).optional(), }), 'trust.dispute': z.object({ - kind: z.literal('opened'), + kind: z.enum(['opened', 'under_review', 'upheld', 'reversed', 'withdrawn']), coverageType: subject.optional(), disputeId: subject, }),