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
16 changes: 16 additions & 0 deletions docs/predictions-api.md
Original file line number Diff line number Diff line change
@@ -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**.
Expand Down
6 changes: 6 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions scripts/repair-unconfirmed-results.ts
Original file line number Diff line number Diff line change
@@ -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<Pool, "query">,
apply: boolean,
): Promise<RepairReport> {
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<void> {
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);
});
}
5 changes: 5 additions & 0 deletions src/openapi/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
41 changes: 39 additions & 2 deletions src/services/claimService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions src/services/marketResolutionService.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string>`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;
});
Expand Down
15 changes: 14 additions & 1 deletion src/workers/predictionsConfirmer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<string | null>`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({
Expand Down
Loading