From f75c2eb09626dd6d707878dcffee3e69a1396cda Mon Sep 17 00:00:00 2001 From: issue-solver-bot Date: Sun, 18 Oct 2026 23:24:08 +0100 Subject: [PATCH] fix: resolve issue #984 --- docs/predictions-api.md | 16 +++ openapi.yaml | 6 + scripts/repair-unconfirmed-results.ts | 97 ++++++++++++++ src/openapi/registry.ts | 5 + src/services/claimService.ts | 41 +++++- src/services/marketResolutionService.ts | 14 +- src/workers/predictionsConfirmer.ts | 15 ++- tests/integration/resolutionScoring.test.ts | 141 ++++++++++++++++++++ tests/marketResolver.test.ts | 29 +++- tests/predictionsConfirmer.test.ts | 25 ++++ 10 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 scripts/repair-unconfirmed-results.ts create mode 100644 tests/integration/resolutionScoring.test.ts diff --git a/docs/predictions-api.md b/docs/predictions-api.md index 73a42eb1..02864aed 100644 --- a/docs/predictions-api.md +++ b/docs/predictions-api.md @@ -1,5 +1,21 @@ # Predictions Listing API +## When is a prediction claimable + +A prediction is claimable only when **all** of the following hold: + +1. Its `status` is `confirmed` (the confirmer matched it to an on-chain `bet_placed` event). +2. Its market is `resolved`. +3. Its `result` is `won`. + +Details: + +- At resolution only `confirmed` rows are scored `won`/`lost`. `pending` and `failed` rows keep `result = NULL`. +- A prediction confirmed after its market resolved is scored in the same `UPDATE` that confirms it, from `markets.winning_outcome`. +- Existing `won`/`lost` values are never overwritten by replayed resolution or confirmation. +- `POST /api/predictions/claim` looks at confirmed rows only. If none is a winner it returns `400 prediction_not_winning`; if the user has more than one confirmed row for the market it returns `409 ambiguous_prediction` (both ids are logged) rather than guessing. +- Legacy rows scored before this rule can be cleaned with `scripts/repair-unconfirmed-results.ts` (dry run by default; `--apply` to write; already-claimed rows are reported, not changed). + ## `GET /api/predictions` Returns a cursor-paginated list of predictions belonging to the **authenticated user**. diff --git a/openapi.yaml b/openapi.yaml index 0966f7a7..5a4ef55d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2817,6 +2817,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + '409': + description: 'ambiguous_prediction: more than one confirmed prediction exists for the user and market' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' '500': description: Soroban transaction submission failed content: diff --git a/scripts/repair-unconfirmed-results.ts b/scripts/repair-unconfirmed-results.ts new file mode 100644 index 00000000..0d6fe2e1 --- /dev/null +++ b/scripts/repair-unconfirmed-results.ts @@ -0,0 +1,97 @@ +/** + * Data repair: reset `result` to NULL on non-confirmed predictions in resolved + * markets that were scored before resolution was limited to confirmed rows. + * + * Dry run by default (reports counts, writes nothing). Pass --apply to write. + * Rows that already have a claim_tx_hash are reported, never modified. + * Users with more than one confirmed row in a market are reported too, since + * claims for them return 409 ambiguous_prediction. + * + * Usage: + * npx ts-node --transpile-only scripts/repair-unconfirmed-results.ts + * npx ts-node --transpile-only scripts/repair-unconfirmed-results.ts --apply + */ +import type { Pool } from "pg"; + +export interface RepairReport { + applied: boolean; + candidates: number; + reset: number; + claimedSkipped: Array<{ id: string; marketId: string; userId: string; claimTxHash: string }>; + duplicateConfirmed: Array<{ marketId: string; userId: string; count: number }>; +} + +export async function repairUnconfirmedResults( + pool: Pick, + apply: boolean, +): Promise { + const candidates = await pool.query( + `SELECT COUNT(*)::int AS n + FROM predictions p JOIN markets m ON m.id = p.market_id + WHERE m.status = 'resolved' AND p.status <> 'confirmed' + AND p.result IS NOT NULL AND p.claim_tx_hash IS NULL`, + ); + + const claimed = await pool.query( + `SELECT p.id, p.market_id AS "marketId", p.user_id AS "userId", p.claim_tx_hash AS "claimTxHash" + FROM predictions p JOIN markets m ON m.id = p.market_id + WHERE m.status = 'resolved' AND p.status <> 'confirmed' + AND p.result IS NOT NULL AND p.claim_tx_hash IS NOT NULL + ORDER BY p.id`, + ); + + const duplicates = await pool.query( + `SELECT market_id AS "marketId", user_id AS "userId", COUNT(*)::int AS count + FROM predictions WHERE status = 'confirmed' + GROUP BY market_id, user_id HAVING COUNT(*) > 1 + ORDER BY market_id, user_id`, + ); + + let reset = 0; + if (apply) { + const res = await pool.query( + `UPDATE predictions p SET result = NULL + FROM markets m + WHERE m.id = p.market_id AND m.status = 'resolved' + AND p.status <> 'confirmed' + AND p.result IS NOT NULL AND p.claim_tx_hash IS NULL`, + ); + reset = res.rowCount ?? 0; + } + + return { + applied: apply, + candidates: candidates.rows[0].n as number, + reset, + claimedSkipped: claimed.rows, + duplicateConfirmed: duplicates.rows, + }; +} + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + const { pool } = await import("../src/db/client"); + try { + const report = await repairUnconfirmedResults(pool, apply); + console.log(`mode: ${apply ? "APPLY" : "DRY RUN (pass --apply to write)"}`); + console.log(`non-confirmed rows with a result (unclaimed): ${report.candidates}`); + console.log(`rows reset: ${report.reset}`); + console.log(`claimed non-confirmed rows (reported, not modified): ${report.claimedSkipped.length}`); + for (const r of report.claimedSkipped) { + console.log(` prediction=${r.id} market=${r.marketId} user=${r.userId} claimTx=${r.claimTxHash}`); + } + console.log(`users with multiple confirmed rows in a market: ${report.duplicateConfirmed.length}`); + for (const d of report.duplicateConfirmed) { + console.log(` market=${d.marketId} user=${d.userId} confirmed=${d.count}`); + } + } finally { + await pool.end(); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 263d8c97..2ea4b9f6 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -1400,6 +1400,11 @@ registry.registerPath({ description: "Market or prediction not found", content: { "application/json": { schema: ErrorBody } }, }, + 409: { + description: + "ambiguous_prediction: more than one confirmed prediction exists for the user and market", + content: { "application/json": { schema: ErrorBody } }, + }, 500: { description: "Soroban transaction submission failed", content: { "application/json": { schema: ErrorBody } }, diff --git a/src/services/claimService.ts b/src/services/claimService.ts index a1ca2c98..b9dad9ef 100644 --- a/src/services/claimService.ts +++ b/src/services/claimService.ts @@ -133,7 +133,9 @@ export async function claimWinnings( } // ── 2. Look up the user's prediction ────────────────────────────────── - const [prediction] = await db + // Only confirmed rows are claimable. Fetch up to two so duplicates are + // detected deterministically instead of guessing with an unordered limit(1). + const confirmedRows = await db .select({ id: predictions.id, result: predictions.result, @@ -145,11 +147,46 @@ export async function claimWinnings( and( eq(predictions.marketId, marketId), eq(predictions.userId, userId), + eq(predictions.status, "confirmed"), ), ) - .limit(1); + .orderBy(predictions.id) + .limit(2); + + if (confirmedRows.length > 1) { + logger.error( + { marketId, userId, predictionIds: confirmedRows.map((r) => r.id) }, + "claim: multiple confirmed predictions for user and market", + ); + throw new ClaimError( + 409, + "ambiguous_prediction", + "Multiple confirmed predictions found for this user and market", + ); + } + + const prediction = confirmedRows[0]; if (!prediction) { + // Unconfirmed (pending/failed) rows exist but none is claimable. + const [unconfirmed] = await db + .select({ id: predictions.id }) + .from(predictions) + .where( + and( + eq(predictions.marketId, marketId), + eq(predictions.userId, userId), + ), + ) + .orderBy(predictions.id) + .limit(1); + if (unconfirmed) { + throw new ClaimError( + 400, + "prediction_not_winning", + "No confirmed winning prediction found. Only confirmed winning predictions can be claimed", + ); + } throw new ClaimError( 404, "prediction_not_found", diff --git a/src/services/marketResolutionService.ts b/src/services/marketResolutionService.ts index f2825079..9ed0aa7d 100644 --- a/src/services/marketResolutionService.ts +++ b/src/services/marketResolutionService.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import type { Db } from "../db"; import { markets, predictions, webhookSubscriptions } from "../db/schema"; import { logger } from "../config/logger"; @@ -185,12 +185,22 @@ export class DrizzleMarketResolutionRepo implements MarketResolutionRepo { .where(eq(markets.id, marketId)); // Single bulk UPDATE avoids N+1 round-trips; the DB evaluates the CASE. + // Only confirmed rows are scored: pending/failed rows never landed + // on-chain and must stay unclaimable (result NULL). Rows confirmed later + // are scored by the confirmer. `result IS NULL` keeps replays from + // flipping an existing won/lost value. await tx .update(predictions) .set({ result: sql`CASE WHEN ${predictions.outcome} = ${winningOutcome} THEN 'won' ELSE 'lost' END`, }) - .where(eq(predictions.marketId, marketId)); + .where( + and( + eq(predictions.marketId, marketId), + eq(predictions.status, "confirmed"), + isNull(predictions.result), + ), + ); resolved = true; }); diff --git a/src/workers/predictionsConfirmer.ts b/src/workers/predictionsConfirmer.ts index 619f5c2a..c9b9ebf4 100644 --- a/src/workers/predictionsConfirmer.ts +++ b/src/workers/predictionsConfirmer.ts @@ -37,7 +37,7 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { env } from "../config/env"; import { logger } from "../config/logger"; import { pool } from "../db/client"; -import { predictions, indexerEvents } from "../db/schema"; +import { predictions, indexerEvents, markets } from "../db/schema"; import type { Db } from "../db"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -306,6 +306,19 @@ export class DrizzlePredictionsConfirmerRepo implements PredictionsConfirmerRepo status: "confirmed", confirmAttempts: 0, lastError: null, + // Late confirmation: if the market is already resolved, score the row + // in this same UPDATE from markets.winning_outcome. An existing + // won/lost value is never overwritten (idempotent). + result: sql`COALESCE( + ${predictions.result}, + (SELECT CASE + WHEN ${markets.status} = 'resolved' AND ${markets.winningOutcome} IS NOT NULL + THEN CASE WHEN ${markets.winningOutcome} = ${predictions.outcome} THEN 'won' ELSE 'lost' END + ELSE NULL + END + FROM ${markets} + WHERE ${markets.id} = ${predictions.marketId}) + )`, }) .where(inArray(predictions.id, predictionIds)) .returning({ diff --git a/tests/integration/resolutionScoring.test.ts b/tests/integration/resolutionScoring.test.ts new file mode 100644 index 00000000..82fbbe8e --- /dev/null +++ b/tests/integration/resolutionScoring.test.ts @@ -0,0 +1,141 @@ +import { closeDb, pool } from "../../src/db/client"; +import { db } from "../../src/db"; +import { DrizzleMarketResolutionRepo } from "../../src/services/marketResolutionService"; +import { DrizzlePredictionsConfirmerRepo } from "../../src/workers/predictionsConfirmer"; +import { claimWinnings, ClaimError } from "../../src/services/claimService"; +import { repairUnconfirmedResults } from "../../scripts/repair-unconfirmed-results"; +import { drizzle } from "drizzle-orm/node-postgres"; + +async function seedUser(addr: string): Promise { + const r = await pool.query(`INSERT INTO users (stellar_address) VALUES ($1) RETURNING id`, [addr]); + return r.rows[0].id as string; +} + +async function seedMarket(id: string, status = "active", winning: string | null = null) { + await pool.query( + `INSERT INTO markets (id, question, status, resolution_time, indexed_ledger, archived, version, featured, winning_outcome) + VALUES ($1, 'q', $2, now(), 1, false, 1, false, $3)`, + [id, status, winning], + ); +} + +async function seedPrediction( + marketId: string, + userId: string, + outcome: string, + status: string, + extra: { result?: string | null; claimTx?: string | null } = {}, +): Promise { + const r = await pool.query( + `INSERT INTO predictions (market_id, user_id, outcome, amount, status, tx_hash, result, claim_tx_hash) + VALUES ($1, $2, $3, '10', $4, $5, $6, $7) RETURNING id`, + [marketId, userId, outcome, status, `tx-${Math.random()}`, extra.result ?? null, extra.claimTx ?? null], + ); + return r.rows[0].id as string; +} + +async function resultOf(id: string): Promise { + const r = await pool.query(`SELECT result FROM predictions WHERE id = $1`, [id]); + return r.rows[0].result as string | null; +} + +const suffix = Date.now().toString(36); +const builder = { submitClaimTx: jest.fn(async () => "hash") }; + +afterAll(async () => { + await closeDb(); +}); + +describe("resolution scoring (integration)", () => { + beforeEach(() => builder.submitClaimTx.mockClear()); + + it("scores only confirmed rows; replay changes nothing", async () => { + const m = `m-score-${suffix}`; + const u = await seedUser(`G-SCORE-${suffix}`); + await seedMarket(m); + const c = await seedPrediction(m, u, "YES", "confirmed"); + const p = await seedPrediction(m, u, "YES", "pending"); + const f = await seedPrediction(m, u, "YES", "failed"); + + const repo = new DrizzleMarketResolutionRepo(db); + expect(await repo.atomicResolve(m, "YES")).toBe(true); + expect(await repo.atomicResolve(m, "NO")).toBe(false); + + expect(await resultOf(c)).toBe("won"); + expect(await resultOf(p)).toBeNull(); + expect(await resultOf(f)).toBeNull(); + }); + + it("claim with only a failed winning row → 400 prediction_not_winning, no RPC", async () => { + const m = `m-failclaim-${suffix}`; + const u = await seedUser(`G-FAILCLAIM-${suffix}`); + await seedMarket(m); + await seedPrediction(m, u, "YES", "failed"); + await new DrizzleMarketResolutionRepo(db).atomicResolve(m, "YES"); + + await expect( + claimWinnings({ marketId: m, userId: u, stellarAddress: "G" }, builder), + ).rejects.toMatchObject({ status: 400, code: "prediction_not_winning" }); + expect(builder.submitClaimTx).not.toHaveBeenCalled(); + }); + + it("claim uses the confirmed row when a failed duplicate exists", async () => { + const m = `m-dup-${suffix}`; + const u = await seedUser(`G-DUP-${suffix}`); + await seedMarket(m); + await seedPrediction(m, u, "YES", "failed"); + const c = await seedPrediction(m, u, "YES", "confirmed"); + await new DrizzleMarketResolutionRepo(db).atomicResolve(m, "YES"); + + const res = await claimWinnings({ marketId: m, userId: u, stellarAddress: "G" }, builder); + expect(res.predictionId).toBe(c); + }); + + it("two confirmed rows → 409 ambiguous_prediction", async () => { + const m = `m-amb-${suffix}`; + const u = await seedUser(`G-AMB-${suffix}`); + await seedMarket(m); + await seedPrediction(m, u, "YES", "confirmed"); + await seedPrediction(m, u, "YES", "confirmed"); + await new DrizzleMarketResolutionRepo(db).atomicResolve(m, "YES"); + + const err = await claimWinnings({ marketId: m, userId: u, stellarAddress: "G" }, builder).catch((e) => e); + expect(err).toBeInstanceOf(ClaimError); + expect(err).toMatchObject({ status: 409, code: "ambiguous_prediction" }); + expect(builder.submitClaimTx).not.toHaveBeenCalled(); + }); + + it("prediction confirmed after resolution is scored in the confirm update", async () => { + const m = `m-late-${suffix}`; + const u = await seedUser(`G-LATE-${suffix}`); + await seedMarket(m, "resolved", "YES"); + const p = await seedPrediction(m, u, "NO", "pending"); + + const repo = new DrizzlePredictionsConfirmerRepo(drizzle(pool)); + await repo.confirmBatch([p]); + expect(await resultOf(p)).toBe("lost"); + await repo.confirmBatch([p]); + expect(await resultOf(p)).toBe("lost"); + }); + + it("repair script: dry run writes nothing; --apply resets only unclaimed non-confirmed rows", async () => { + const m = `m-repair-${suffix}`; + const u = await seedUser(`G-REPAIR-${suffix}`); + await seedMarket(m, "resolved", "YES"); + const pending = await seedPrediction(m, u, "YES", "pending", { result: "won" }); + const claimed = await seedPrediction(m, u, "YES", "failed", { result: "won", claimTx: "ctx" }); + const confirmed = await seedPrediction(m, u, "YES", "confirmed", { result: "won" }); + + const dry = await repairUnconfirmedResults(pool, false); + expect(dry.reset).toBe(0); + expect(dry.candidates).toBeGreaterThanOrEqual(1); + expect(await resultOf(pending)).toBe("won"); + + const applied = await repairUnconfirmedResults(pool, true); + expect(applied.reset).toBeGreaterThanOrEqual(1); + expect(applied.claimedSkipped.map((r) => r.id)).toContain(claimed); + expect(await resultOf(pending)).toBeNull(); + expect(await resultOf(claimed)).toBe("won"); + expect(await resultOf(confirmed)).toBe("won"); + }); +}); diff --git a/tests/marketResolver.test.ts b/tests/marketResolver.test.ts index 1a500178..0ba14e0c 100644 --- a/tests/marketResolver.test.ts +++ b/tests/marketResolver.test.ts @@ -202,6 +202,7 @@ interface MemPrediction { marketId: string; outcome: string; result: string | null; + status?: string; } class InMemoryMarketResolutionRepo implements MarketResolutionRepo { @@ -217,7 +218,7 @@ class InMemoryMarketResolutionRepo implements MarketResolutionRepo { market.winningOutcome = winningOutcome; for (const p of this.predictions) { - if (p.marketId === marketId) { + if (p.marketId === marketId && (p.status ?? "confirmed") === "confirmed" && p.result === null) { p.result = p.outcome === winningOutcome ? "won" : "lost"; } } @@ -248,6 +249,32 @@ const FIXTURE_RESOLVE: MarketResolvedEvent = { timestamp: 1_700_000_000, }; +describe("resolveMarket() — only confirmed predictions are scored", () => { + it("leaves pending and failed rows with NULL result", async () => { + const repo = new InMemoryMarketResolutionRepo(); + repo.markets.push({ id: "mkt-1", status: "active", winningOutcome: null }); + repo.predictions.push( + { id: "c", marketId: "mkt-1", outcome: "YES", result: null, status: "confirmed" }, + { id: "p", marketId: "mkt-1", outcome: "YES", result: null, status: "pending" }, + { id: "f", marketId: "mkt-1", outcome: "YES", result: null, status: "failed" }, + ); + await resolveMarket(repo, FIXTURE_RESOLVE, makeEmitter()); + const byId = Object.fromEntries(repo.predictions.map((p) => [p.id, p.result])); + expect(byId).toEqual({ c: "won", p: null, f: null }); + }); + + it("replay does not change existing results", async () => { + const repo = new InMemoryMarketResolutionRepo(); + repo.markets.push({ id: "mkt-1", status: "active", winningOutcome: null }); + repo.predictions.push( + { id: "c", marketId: "mkt-1", outcome: "NO", result: null, status: "confirmed" }, + ); + await resolveMarket(repo, FIXTURE_RESOLVE, makeEmitter()); + await resolveMarket(repo, { ...FIXTURE_RESOLVE, winningOutcome: "NO" }, makeEmitter()); + expect(repo.predictions[0].result).toBe("lost"); + }); +}); + describe("resolveMarket() — in-memory fixture tests (end-to-end logic)", () => { it("marks the market as resolved with the correct winning outcome", async () => { const repo = seedRepo(); diff --git a/tests/predictionsConfirmer.test.ts b/tests/predictionsConfirmer.test.ts index ebbdda26..449571f9 100644 --- a/tests/predictionsConfirmer.test.ts +++ b/tests/predictionsConfirmer.test.ts @@ -65,6 +65,31 @@ function makeRepo( }; } +// ─── Late-confirmation scoring ──────────────────────────────────────────── + +describe("DrizzlePredictionsConfirmerRepo.confirmBatch — late scoring", () => { + it("sets result in the same UPDATE from markets.winning_outcome without overwriting", async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { PgDialect } = require("drizzle-orm/pg-core"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DrizzlePredictionsConfirmerRepo } = require("../src/workers/predictionsConfirmer"); + const captured: { sql?: string } = {}; + const chain: Record = {}; + chain.update = () => chain; + chain.set = (v: { result: unknown }) => { + captured.sql = new PgDialect().sqlToQuery(v.result).sql; + return chain; + }; + chain.where = () => chain; + chain.returning = async () => []; + const repo = new DrizzlePredictionsConfirmerRepo(chain); + await repo.confirmBatch(["id-1"]); + expect(captured.sql).toMatch(/COALESCE/); + expect(captured.sql).toMatch(/winning_outcome/); + expect(captured.sql).toMatch(/'resolved'/); + }); +}); + // ─── Helpers ────────────────────────────────────────────────────────────── function makeService(