diff --git a/docs/predictions-api.md b/docs/predictions-api.md index 02864aed..a2ec036d 100644 --- a/docs/predictions-api.md +++ b/docs/predictions-api.md @@ -13,7 +13,7 @@ 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. +- `POST /api/predictions/claim/prepare` 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` @@ -169,3 +169,155 @@ discarded rather than mis-paginating. | Scope | Authenticated user only | Any Stellar address | | Filters | marketId, status, outcome | status only | | Data source | `predictionRepo.listPredictions`| `userService.getUserPredictions` | + +--- + +# Claiming winnings (user-signed) + +`claim_winnings(user, market_id, claim_nonce)` on the Predictify contract calls +`user.require_auth()`, so **only the user's wallet can authorise a claim**. The +backend therefore never signs and never submits a claim on its own. A claim is a +two-step prepare / sign / submit flow. + +## Sequence + +``` +Wallet / client Backend Soroban RPC + | | | + |-- POST /claim/prepare --------->| | + | { marketId } |-- getAccount(user) ------------->| + | |-- simulate claim_winnings ------>| + | | (user = source account) | + |<-- { claimId, xdr, passphrase, | store claims row "prepared" | + | expiresAt } ---------------| (expected tx hash, nonce, ttl) | + | | | + | user signs xdr in the wallet | | + | | | + |-- POST /claim ----------------->| | + | { claimId, signedXdr } | verify: source, contract, | + | | function, args, inner tx hash | + | | prepared -> submitting (atomic) | + | |-- sendTransaction -------------->| + |<-- 200 { claimId, status:paid, |<-- PENDING / DUPLICATE ----------| + | claimTxHash, ... } ----| submitting -> paid | + | | | + | settle-confirmer finalises `paid` rows (settled / rejected) +``` + +## `POST /api/predictions/claim/prepare` + +Request: `{ "marketId": "mkt_5cae97f9_0" }`. `marketId` must be a valid Soroban +`Symbol`: 1–32 characters of `[A-Za-z0-9_]`. + +Response `200`: + +```json +{ + "data": { + "claimId": "0b7a5d5e-3c1f-4e0a-9d51-6d9f9a1c2b11", + "xdr": "AAAAAgAAAA...", + "networkPassphrase": "Test SDF Network ; September 2015", + "expiresAt": "2026-10-19T12:05:00.000Z" + } +} +``` + +The transaction is `claim_winnings(user: Address, market_id: Symbol, +claim_nonce: u64)` with the **user's** account as source, already simulated and +assembled (fee and Soroban footprint set). It is valid for 5 minutes. + +`claim_nonce` is `0` for a first claim and goes up by one for each later claim. +The backend simulates `get_claim_nonce` when the contract exports it (the slim +testnet build does not). If it does not, `0` is used only when the user has no +earlier paid or settled claim for the market; otherwise the call fails with +`claim_nonce_unavailable`. + +Calling prepare again while a claim is outstanding returns the **same** claim +(same `claimId` and `xdr`) while it is still valid. At most one non-terminal +claim (`prepared`, `submitting` or `paid`) exists per user and market. + +## `POST /api/predictions/claim` + +Request: `{ "claimId": "", "signedXdr": "" }`. `signedXdr` is +base64 and at most 16 KB. + +Response `200`: + +```json +{ + "data": { + "claimId": "0b7a5d5e-3c1f-4e0a-9d51-6d9f9a1c2b11", + "status": "paid", + "predictionId": "11111111-1111-1111-1111-111111111111", + "result": "won", + "claimTxHash": "9f2c…", + "claimedAt": "2026-10-19T12:00:03.000Z" + } +} +``` + +### Why the transaction hash is the authority + +A wallet signature does not change a transaction's hash. The backend stores the +hash of the transaction it simulated and accepts a signed envelope only when its +inner transaction hash is identical, which proves the envelope is exactly the +prepared transaction: same source, contract, function, arguments, fee, +footprint and time bounds. Before comparing hashes the backend also checks the +source account (must be the caller), contract id, function name and each +argument individually. Those checks exist to return a clear error code, not to +replace the hash check. A fee-bump wrapper is accepted if its inner transaction +passes. + +## Claim states + +| Status | Meaning | +|--------------|-------------------------------------------------------------------------| +| `prepared` | Unsigned transaction issued; waiting for the signed XDR. | +| `submitting` | One request owns the claim and is calling `sendTransaction`. | +| `paid` | RPC returned `PENDING` or `DUPLICATE`; `settlement_tx` is set. | +| `failed` | RPC returned `ERROR`; `error_code` holds the mapped code. | +| `expired` | Not submitted before `expiresAt`. | +| `settled` / `rejected` | Final result written by the settle-confirmer for `paid` rows. | + +`prepared → submitting` is a conditional `UPDATE … WHERE status = 'prepared'`, +so concurrent submits of one claim reach the RPC exactly once; the others get +the stored result or `409 claim_in_progress`. If `sendTransaction` throws or +answers `TRY_AGAIN_LATER`, the claim returns to `prepared` and the response is +`503 claim_rpc_unavailable` — re-submitting the same signed transaction is safe. + +## Idempotency + +- Replaying a submit for a `paid` (or `settled`) claim returns `200` with the + stored result and the same `claimTxHash`. +- Submitting a `failed` or `expired` claim returns `409`. Call prepare again to + get a new claim. +- These rules hold independently of the global `Idempotency-Key` middleware. + +## Error codes + +Responses carry only these stable codes and fixed messages. Raw RPC and SDK +errors are written to structured logs (with the request correlation id) and are +never returned. + +| Code | Status | Endpoint | Meaning | +|---------------------------|--------|----------|----------------------------------------------------------------------| +| `validation_error` | 400 | both | Bad body (`marketId` not a Symbol, `signedXdr` not base64 / over 16 KB, …). | +| `market_not_found` | 404 | prepare | No such market. | +| `market_not_resolved` | 400 | prepare | Market is not `resolved`. | +| `prediction_not_found` | 404 | prepare | The caller has no prediction in the market. | +| `prediction_not_winning` | 400 | prepare | The prediction did not win. | +| `claim_already_submitted` | 409 | prepare | Winnings were already claimed (a `paid` / `settled` claim exists). | +| `claim_in_progress` | 409 | both | A claim for this market is being submitted. | +| `claim_nonce_unavailable` | 409 | prepare | `claim_nonce` cannot be determined for a repeat claim. | +| `claim_simulation_failed` | 422 | prepare | The RPC rejected the simulation. | +| `claim_not_found` | 404 | submit | Unknown `claimId`, or it belongs to another user. | +| `claim_xdr_mismatch` | 400 | submit | The signed XDR is unsigned, malformed, or not the prepared claim. The RPC is not called. | +| `claim_expired` | 409 | submit | Submitted after `expiresAt` (or the network reported `txTooLate`). | +| `claim_rejected` | 422 / 409 | submit | The network rejected the transaction (409 on replay of a failed claim). | +| `claim_rpc_unavailable` | 503 | submit | The RPC could not be reached; the claim remains submittable. | + +## Metrics + +- `claims_prepared_total` — unsigned claims prepared. +- `claims_submitted_total{outcome}` — submit attempts by outcome: `paid`, + `failed`, `expired`, `mismatch`, `error`. diff --git a/drizzle/0027_claims_prepare_submit.sql b/drizzle/0027_claims_prepare_submit.sql new file mode 100644 index 00000000..b803cdda --- /dev/null +++ b/drizzle/0027_claims_prepare_submit.sql @@ -0,0 +1,15 @@ +-- Migration: user-signed claim_winnings prepare/submit flow. +-- Adds the columns the prepare/submit state machine needs on `claims` +-- (prepared -> submitting -> paid | failed | expired) and a partial unique +-- index allowing at most one non-terminal claim per (user, market). + +ALTER TABLE "claims" + ADD COLUMN IF NOT EXISTS "claim_nonce" text, + ADD COLUMN IF NOT EXISTS "expected_tx_hash" text, + ADD COLUMN IF NOT EXISTS "prepared_xdr" text, + ADD COLUMN IF NOT EXISTS "expires_at" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "error_code" text; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "claims_one_active_per_user_market_idx" + ON "claims" ("user_id", "market_id") + WHERE "status" in ('prepared', 'submitting', 'paid'); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c25cc95d..6ccae00b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785335000000, "tag": "0026_add_predictions_confirmer", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1785400000000, + "tag": "0027_claims_prepare_submit", + "breakpoints": true } ] } diff --git a/openapi.yaml b/openapi.yaml index 5a4ef55d..ee177f58 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -457,25 +457,111 @@ components: - cached required: - data + ClaimPrepareResponse: + type: object + properties: + data: + type: object + properties: + claimId: + type: string + format: uuid + description: Pass back to POST /api/predictions/claim. + xdr: + type: string + description: >- + Unsigned, simulated claim_winnings(user, market_id, claim_nonce) transaction (base64 XDR) with the + caller as source account. Sign it with the user's wallet. + networkPassphrase: + type: string + example: Test SDF Network ; September 2015 + expiresAt: + type: string + format: date-time + description: After this instant the prepared claim can no longer be submitted. + required: + - claimId + - xdr + - networkPassphrase + - expiresAt + required: + - data + ClaimErrorBody: + type: object + properties: + error: + type: object + properties: + code: + type: string + enum: + - validation_error + - market_not_found + - market_not_resolved + - prediction_not_found + - prediction_not_winning + - ambiguous_prediction + - claim_not_found + - claim_already_submitted + - claim_in_progress + - claim_simulation_failed + - claim_rejected + - claim_expired + - claim_xdr_mismatch + - claim_nonce_unavailable + - claim_rpc_unavailable + message: + type: string + requestId: + type: string + details: + nullable: true + required: + - code + required: + - error + ClaimPrepareRequest: + type: object + properties: + marketId: + type: string + minLength: 1 + maxLength: 32 + pattern: ^[A-Za-z0-9_]+$ + description: On-chain market id; must be a valid Soroban Symbol (<= 32 chars of [A-Za-z0-9_]). + example: mkt_5cae97f9_0 + required: + - marketId + additionalProperties: false ClaimResponse: type: object properties: data: type: object properties: + claimId: + type: string + format: uuid + status: + type: string + description: paid | settled predictionId: type: string + nullable: true result: type: string nullable: true claimTxHash: type: string nullable: true + description: Soroban transaction hash (settlement_tx). claimedAt: type: string nullable: true format: date-time required: + - claimId + - status - predictionId - result - claimTxHash @@ -485,11 +571,17 @@ components: ClaimRequest: type: object properties: - marketId: + claimId: + type: string + format: uuid + signedXdr: type: string minLength: 1 + maxLength: 16384 + description: The prepared transaction, signed by the user's wallet (base64 XDR, <= 16 KB). required: - - marketId + - claimId + - signedXdr additionalProperties: false LeaderboardEntry: type: object @@ -2776,15 +2868,75 @@ paths: error: code: not_found requestId: req_abc123 + /api/predictions/claim/prepare: + post: + operationId: prepareClaim + tags: + - Predictions + summary: Prepare an unsigned winnings claim for wallet signing + description: >- + Step 1 of the user-signed claim flow. Validates that the market is resolved and the caller has a winning + prediction, resolves claim_nonce, then builds and simulates an unsigned claim_winnings(user, market_id, + claim_nonce) transaction with the caller as source. The server never signs. Calling it again while a claim is + outstanding returns that same claim. Error codes: market_not_found, market_not_resolved, prediction_not_found, + prediction_not_winning, ambiguous_prediction, claim_already_submitted, claim_in_progress, + claim_nonce_unavailable, claim_simulation_failed. + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimPrepareRequest' + responses: + '200': + description: Unsigned claim transaction prepared + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimPrepareResponse' + '400': + description: Validation error, market not resolved, or prediction not winning + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimErrorBody' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Market or prediction not found + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimErrorBody' + '409': + description: ambiguous_prediction, already claimed, claim in progress, or claim_nonce_unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimErrorBody' + '422': + description: claim_simulation_failed + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimErrorBody' /api/predictions/claim: post: operationId: claimPrediction tags: - Predictions - summary: Claim winnings after market resolution + summary: Submit a wallet-signed winnings claim description: >- - Builds and submits a Soroban claim transaction for the authenticated user's winning prediction. Idempotent via - Idempotency-Key header and internal guard. + Step 2 of the user-signed claim flow. Accepts the signed XDR for a prepared claim, rejects it unless it is + exactly the prepared transaction (network, source account, contract, function, arguments and inner transaction + hash), submits it, and records a claims row for the settle-confirmer. Replaying a submit for a paid claim + returns the stored result with 200; a failed or expired claim returns 409. Error codes: claim_not_found, + claim_xdr_mismatch, claim_expired, claim_in_progress, claim_rejected, claim_rpc_unavailable. security: - bearerAuth: [] requestBody: @@ -2794,17 +2946,17 @@ paths: $ref: '#/components/schemas/ClaimRequest' responses: '200': - description: Claim successful (or previously claimed) + description: Claim submitted (or previously submitted) content: application/json: schema: $ref: '#/components/schemas/ClaimResponse' '400': - description: Market not resolved, prediction not winning, or validation error + description: Validation error or claim_xdr_mismatch content: application/json: schema: - $ref: '#/components/schemas/ErrorBody' + $ref: '#/components/schemas/ClaimErrorBody' '401': description: Unauthorized content: @@ -2812,23 +2964,29 @@ paths: schema: $ref: '#/components/schemas/ErrorBody' '404': - description: Market or prediction not found + description: claim_not_found content: application/json: schema: - $ref: '#/components/schemas/ErrorBody' + $ref: '#/components/schemas/ClaimErrorBody' '409': - description: 'ambiguous_prediction: more than one confirmed prediction exists for the user and market' + description: claim_expired, claim_in_progress, or a previously failed claim content: application/json: schema: - $ref: '#/components/schemas/ErrorBody' - '500': - description: Soroban transaction submission failed + $ref: '#/components/schemas/ClaimErrorBody' + '422': + description: claim_rejected by the network content: application/json: schema: - $ref: '#/components/schemas/ErrorBody' + $ref: '#/components/schemas/ClaimErrorBody' + '503': + description: claim_rpc_unavailable — the claim is still submittable; retry + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimErrorBody' /api/leaderboard: get: operationId: getLeaderboard diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts index ab2a7ad7..391b5c9d 100644 --- a/scripts/check-openapi.ts +++ b/scripts/check-openapi.ts @@ -55,6 +55,7 @@ export const EXPECTED_ROUTES: RouteEntry[] = [ { method: "get", path: "/api/comments" }, { method: "post", path: "/api/comments" }, { method: "get", path: "/api/markets/{id}/prediction-count" }, + { method: "post", path: "/api/predictions/claim/prepare" }, { method: "post", path: "/api/predictions/claim" }, { method: "get", path: "/api/rate-limit/status" }, { method: "get", path: "/api/rate-limit" }, diff --git a/src/db/schema.ts b/src/db/schema.ts index 5cfc70df..04107519 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -9,7 +9,9 @@ import { jsonb, index, primaryKey, + uniqueIndex, } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; export const users = pgTable( "users", @@ -254,26 +256,54 @@ export const fraudFlags = pgTable( export type FraudFlag = typeof fraudFlags.$inferSelect; -export const claims = pgTable("claims", { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id") - .notNull() - .references(() => users.id), - marketId: text("market_id") - .notNull() - .references(() => markets.id), - amount: text("amount").notNull(), - status: text("status").notNull().default("pending"), - settlementTx: text("settlement_tx"), - settleAttempts: integer("settle_attempts").notNull().default(0), - nextSettleAttemptAt: timestamp("next_settle_attempt_at", { - withTimezone: true, +/** + * claims — one row per user-signed `claim_winnings` attempt. + * + * State machine: prepared → submitting → paid | failed, plus prepared → + * expired. `paid` rows are finalized (`settled` / `rejected`) by the + * settle-confirmer. A partial unique index allows at most one non-terminal + * (prepared / submitting / paid) claim per (user, market). + */ +export const claims = pgTable( + "claims", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + marketId: text("market_id") + .notNull() + .references(() => markets.id), + amount: text("amount").notNull(), + status: text("status").notNull().default("pending"), + settlementTx: text("settlement_tx"), + settleAttempts: integer("settle_attempts").notNull().default(0), + nextSettleAttemptAt: timestamp("next_settle_attempt_at", { + withTimezone: true, + }), + settledAt: timestamp("settled_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + /** `claim_nonce` (u64, decimal string) the prepared transaction was built with. */ + claimNonce: text("claim_nonce"), + /** Hex hash of the prepared (unsigned) transaction — the submit-time authority. */ + expectedTxHash: text("expected_tx_hash"), + /** Prepared unsigned transaction envelope (base64 XDR). */ + preparedXdr: text("prepared_xdr"), + /** After this instant a prepared claim can no longer be submitted. */ + expiresAt: timestamp("expires_at", { withTimezone: true }), + /** Stable error code (never raw SDK text) for failed / expired claims. */ + errorCode: text("error_code"), + }, + (t) => ({ + claimsOneActivePerUserMarketIdx: uniqueIndex( + "claims_one_active_per_user_market_idx", + ) + .on(t.userId, t.marketId) + .where(sql`${t.status} in ('prepared', 'submitting', 'paid')`), }), - settledAt: timestamp("settled_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); +); export const disputes = pgTable("disputes", { id: uuid("id").primaryKey().defaultRandom(), diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index 26941667..217f804b 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -173,6 +173,19 @@ export const settleConfirmerFailedTotal = new Counter({ registers: [register], }); +export const claimsPreparedTotal = new Counter({ + name: "claims_prepared_total", + help: "Total number of unsigned claim_winnings transactions prepared for user signing", + registers: [register], +}); + +export const claimsSubmittedTotal = new Counter({ + name: "claims_submitted_total", + help: "Total number of signed claim submissions, segmented by outcome (paid, failed, expired, mismatch, error)", + labelNames: ["outcome"] as const, + registers: [register], +}); + export const signupAnomalyScansTotal = new Counter({ name: "signup_anomaly_scans_total", help: "Total number of signup-rate anomaly scans completed", diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 2ea4b9f6..30a50d02 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -1357,57 +1357,175 @@ registry.registerPath({ }, }); +const ClaimMarketId = z + .string() + .min(1) + .max(32) + .regex(/^[A-Za-z0-9_]+$/) + .openapi({ + description: "On-chain market id; must be a valid Soroban Symbol (<= 32 chars of [A-Za-z0-9_]).", + example: "mkt_5cae97f9_0", + }); + +const ClaimPrepareRequest = z + .object({ marketId: ClaimMarketId }) + .strict() + .openapi("ClaimPrepareRequest"); + +const ClaimPrepareResponse = z + .object({ + data: z.object({ + claimId: z.string().uuid().openapi({ description: "Pass back to POST /api/predictions/claim." }), + xdr: z.string().openapi({ + description: + "Unsigned, simulated claim_winnings(user, market_id, claim_nonce) transaction " + + "(base64 XDR) with the caller as source account. Sign it with the user's wallet.", + }), + networkPassphrase: z.string().openapi({ example: "Test SDF Network ; September 2015" }), + expiresAt: z.string().datetime().openapi({ + description: "After this instant the prepared claim can no longer be submitted.", + }), + }), + }) + .openapi("ClaimPrepareResponse"); + const ClaimRequest = z - .object({ marketId: z.string().min(1) }) + .object({ + claimId: z.string().uuid(), + signedXdr: z.string().min(1).max(16384).openapi({ + description: "The prepared transaction, signed by the user's wallet (base64 XDR, <= 16 KB).", + }), + }) .strict() .openapi("ClaimRequest"); const ClaimResponse = z .object({ data: z.object({ - predictionId: z.string(), + claimId: z.string().uuid(), + status: z.string().openapi({ description: "paid | settled" }), + predictionId: z.string().nullable(), result: z.string().nullable(), - claimTxHash: z.string().nullable(), + claimTxHash: z.string().nullable().openapi({ description: "Soroban transaction hash (settlement_tx)." }), claimedAt: z.string().datetime().nullable(), }), }) .openapi("ClaimResponse"); +const ClaimErrorBody = z + .object({ + error: z.object({ + code: z.enum([ + "validation_error", + "market_not_found", + "market_not_resolved", + "prediction_not_found", + "prediction_not_winning", + "ambiguous_prediction", + "claim_not_found", + "claim_already_submitted", + "claim_in_progress", + "claim_simulation_failed", + "claim_rejected", + "claim_expired", + "claim_xdr_mismatch", + "claim_nonce_unavailable", + "claim_rpc_unavailable", + ]), + message: z.string().optional(), + requestId: z.string().optional(), + details: z.any().optional(), + }), + }) + .openapi("ClaimErrorBody"); + +registry.registerPath({ + method: "post", + path: "/api/predictions/claim/prepare", + operationId: "prepareClaim", + tags: ["Predictions"], + summary: "Prepare an unsigned winnings claim for wallet signing", + description: + "Step 1 of the user-signed claim flow. Validates that the market is resolved and the caller " + + "has a winning prediction, resolves claim_nonce, then builds and simulates an unsigned " + + "claim_winnings(user, market_id, claim_nonce) transaction with the caller as source. The " + + "server never signs. Calling it again while a claim is outstanding returns that same claim. " + + "Error codes: market_not_found, market_not_resolved, prediction_not_found, " + + "prediction_not_winning, ambiguous_prediction, claim_already_submitted, claim_in_progress, " + + "claim_nonce_unavailable, claim_simulation_failed.", + security: [{ bearerAuth: [] }], + request: { + body: { content: { "application/json": { schema: ClaimPrepareRequest } } }, + }, + responses: { + 200: { + description: "Unsigned claim transaction prepared", + content: { "application/json": { schema: ClaimPrepareResponse } }, + }, + 400: { + description: "Validation error, market not resolved, or prediction not winning", + content: { "application/json": { schema: ClaimErrorBody } }, + }, + 401: { description: "Unauthorized", content: { "application/json": { schema: ErrorBody } } }, + 404: { + description: "Market or prediction not found", + content: { "application/json": { schema: ClaimErrorBody } }, + }, + 409: { + description: + "ambiguous_prediction, already claimed, claim in progress, or claim_nonce_unavailable", + content: { "application/json": { schema: ClaimErrorBody } }, + }, + 422: { + description: "claim_simulation_failed", + content: { "application/json": { schema: ClaimErrorBody } }, + }, + }, +}); + registry.registerPath({ method: "post", path: "/api/predictions/claim", operationId: "claimPrediction", tags: ["Predictions"], - summary: "Claim winnings after market resolution", + summary: "Submit a wallet-signed winnings claim", description: - "Builds and submits a Soroban claim transaction for the authenticated user's " + - "winning prediction. Idempotent via Idempotency-Key header and internal guard.", + "Step 2 of the user-signed claim flow. Accepts the signed XDR for a prepared claim, rejects " + + "it unless it is exactly the prepared transaction (network, source account, contract, " + + "function, arguments and inner transaction hash), submits it, and records a claims row for " + + "the settle-confirmer. Replaying a submit for a paid claim returns the stored result with " + + "200; a failed or expired claim returns 409. Error codes: claim_not_found, " + + "claim_xdr_mismatch, claim_expired, claim_in_progress, claim_rejected, " + + "claim_rpc_unavailable.", security: [{ bearerAuth: [] }], request: { body: { content: { "application/json": { schema: ClaimRequest } } }, }, responses: { 200: { - description: "Claim successful (or previously claimed)", + description: "Claim submitted (or previously submitted)", content: { "application/json": { schema: ClaimResponse } }, }, 400: { - description: "Market not resolved, prediction not winning, or validation error", - content: { "application/json": { schema: ErrorBody } }, + description: "Validation error or claim_xdr_mismatch", + content: { "application/json": { schema: ClaimErrorBody } }, }, 401: { description: "Unauthorized", content: { "application/json": { schema: ErrorBody } } }, 404: { - description: "Market or prediction not found", - content: { "application/json": { schema: ErrorBody } }, + description: "claim_not_found", + content: { "application/json": { schema: ClaimErrorBody } }, }, 409: { - description: - "ambiguous_prediction: more than one confirmed prediction exists for the user and market", - content: { "application/json": { schema: ErrorBody } }, + description: "claim_expired, claim_in_progress, or a previously failed claim", + content: { "application/json": { schema: ClaimErrorBody } }, }, - 500: { - description: "Soroban transaction submission failed", - content: { "application/json": { schema: ErrorBody } }, + 422: { + description: "claim_rejected by the network", + content: { "application/json": { schema: ClaimErrorBody } }, + }, + 503: { + description: "claim_rpc_unavailable — the claim is still submittable; retry", + content: { "application/json": { schema: ClaimErrorBody } }, }, }, }); diff --git a/src/routes/predictions.ts b/src/routes/predictions.ts index 09658f9d..372c8f36 100644 --- a/src/routes/predictions.ts +++ b/src/routes/predictions.ts @@ -11,14 +11,16 @@ * ------ * - All routes require authentication via `requireAuth`. * - GET / (list) uses keyset pagination; `nextCursor` is null on the last page. - * - POST /claim is idempotent via the global Idempotency-Key middleware. + * - POST /claim/prepare + POST /claim form the user-signed claim flow; the + * server never signs. Submit is idempotent on its own and via the global + * Idempotency-Key middleware. * - GET /:id/explain is a read-only educational endpoint. */ import { Router, type Request, type Response, type NextFunction } from "express"; import { z } from "zod"; import { requireAuth } from "../middleware/requireAuth"; -import { claimWinnings, ClaimError } from "../services/claimService"; +import { prepareClaim, submitClaim, ClaimError } from "../services/claimService"; import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; import { createPerUserRateLimiter } from "../middleware/rateLimit"; @@ -115,75 +117,148 @@ predictionsRouter.use( ); // --------------------------------------------------------------------------- -// POST /api/predictions/claim +// POST /api/predictions/claim/prepare and POST /api/predictions/claim // --------------------------------------------------------------------------- -const claimBodySchema = z +/** Soroban `Symbol`: at most 32 characters of `[A-Za-z0-9_]`. */ +const sorobanSymbolSchema = z + .string() + .min(1, "marketId is required") + .max(32, "marketId must be at most 32 characters") + .regex(/^[A-Za-z0-9_]+$/, "marketId must be a valid Soroban Symbol ([A-Za-z0-9_])"); + +const claimPrepareBodySchema = z + .object({ + marketId: sorobanSymbolSchema, + }) + .strict(); + +/** Signed transaction envelope: base64, at most 16 KB. */ +const MAX_SIGNED_XDR_LENGTH = 16 * 1024; + +const claimSubmitBodySchema = z .object({ - marketId: z.string().min(1, "marketId is required"), + claimId: z.string().uuid("claimId must be a UUID"), + signedXdr: z + .string() + .min(1, "signedXdr is required") + .max(MAX_SIGNED_XDR_LENGTH, "signedXdr exceeds the 16 KB limit") + .regex(/^[A-Za-z0-9+/]+={0,2}$/, "signedXdr must be base64"), }) .strict(); +type ClaimUser = { id: string; stellarAddress: string }; + +function sendClaimValidationError(res: Response, error: z.ZodError): void { + res.status(400).json({ + error: { + code: "validation_error", + details: error.flatten().fieldErrors, + }, + }); +} + +function sendClaimError(res: Response, e: ClaimError): void { + res.status(e.status).json({ + error: { code: e.code, message: e.message, requestId: getRequestId() }, + }); +} + /** - * POST /api/predictions/claim + * POST /api/predictions/claim/prepare * - * Claims winnings for a winning prediction after the parent market has been - * resolved. Builds and submits a Soroban claim transaction, then persists - * the on-chain tx hash on the prediction row. + * Builds and simulates an unsigned `claim_winnings(user, market_id, + * claim_nonce)` transaction with the caller as source. The wallet signs it + * and the result is sent to `POST /api/predictions/claim`. The server never + * signs. A second prepare while a claim is outstanding returns that claim. * - * Request body: - * ```json - * { "marketId": "uuid-or-text-id" } - * ``` - * - * Idempotent via: - * - Internal guard: if claimTxHash is already set, returns existing data. - * - HTTP layer: the global idempotency middleware (Idempotency-Key header). + * Request body: `{ "marketId": "" }` * * Responses: - * 200 — Claim successful (or previously claimed — idempotent replay). - * 400 — Market not resolved, prediction not a winner, or validation error. + * 200 — `{ data: { xdr, networkPassphrase, expiresAt, claimId } }` + * 400 — Validation error, market not resolved, or prediction not winning. * 401 — Missing or invalid Bearer token. * 404 — Market or prediction not found. - * 500 — Soroban transaction submission failed. + * 409 — Already claimed, claim in progress, or claim_nonce_unavailable. + * 422 — claim_simulation_failed. */ -predictionsRouter.post("/claim", async (req, res, next) => { +predictionsRouter.post("/claim/prepare", async (req, res, next) => { try { - const parsed = claimBodySchema.safeParse(req.body); + const parsed = claimPrepareBodySchema.safeParse(req.body); if (!parsed.success) { - res.status(400).json({ - error: { - code: "validation_error", - details: parsed.error.flatten().fieldErrors, - }, - }); + sendClaimValidationError(res, parsed.error); return; } const { marketId } = parsed.data; - const claimUser = (req as unknown as { user: { id: string; stellarAddress: string } }).user; - const requestId = getRequestId(); - + const claimUser = (req as unknown as { user: ClaimUser }).user; logger.info( - { reqId: requestId, marketId, userId: claimUser.id }, - "claim: processing claim request", + { reqId: getRequestId(), marketId, userId: claimUser.id }, + "claim: processing prepare request", ); - const result = await claimWinnings({ + const data = await prepareClaim({ marketId, userId: claimUser.id, stellarAddress: claimUser.stellarAddress, }); + res.status(200).json({ data }); + } catch (e) { + if (e instanceof ClaimError) { + sendClaimError(res, e); + return; + } + next(e); + } +}); +/** + * POST /api/predictions/claim + * + * Accepts the wallet-signed XDR for a prepared claim, checks that it is + * exactly the prepared transaction, submits it, and records the result for + * the settle-confirmer. + * + * Request body: `{ "claimId": "", "signedXdr": "" }` + * + * Idempotent: replaying a submit for a `paid` claim returns the stored result + * with 200; a `failed` or `expired` claim returns 409. This holds + * independently of the global Idempotency-Key middleware. + * + * Responses: + * 200 — Submitted (or previously submitted). + * 400 — Validation error or claim_xdr_mismatch. + * 401 — Missing or invalid Bearer token. + * 404 — Claim not found. + * 409 — claim_expired, claim_in_progress, or a previously failed claim. + * 422 — claim_rejected. + * 503 — claim_rpc_unavailable (safe to retry). + */ +predictionsRouter.post("/claim", async (req, res, next) => { + try { + const parsed = claimSubmitBodySchema.safeParse(req.body); + if (!parsed.success) { + sendClaimValidationError(res, parsed.error); + return; + } + + const { claimId, signedXdr } = parsed.data; + const claimUser = (req as unknown as { user: ClaimUser }).user; logger.info( - { reqId: requestId, marketId, userId: claimUser.id, claimTxHash: result.claimTxHash }, - "claim: completed successfully", + { reqId: getRequestId(), claimId, userId: claimUser.id }, + "claim: processing submit request", ); - res.status(200).json({ data: result }); + const data = await submitClaim({ + claimId, + signedXdr, + userId: claimUser.id, + stellarAddress: claimUser.stellarAddress, + }); + res.status(200).json({ data }); } catch (e) { if (e instanceof ClaimError) { - res.status(e.status).json({ error: { code: e.code, message: e.message } }); + sendClaimError(res, e); return; } next(e); diff --git a/src/services/claimChain.ts b/src/services/claimChain.ts new file mode 100644 index 00000000..253b0649 --- /dev/null +++ b/src/services/claimChain.ts @@ -0,0 +1,305 @@ +/** + * claimChain.ts + * + * Soroban side of the user-signed `claim_winnings` flow. + * + * The contract entrypoint is + * claim_winnings(user: Address, market_id: Symbol, claim_nonce: u64) + * and it calls `user.require_auth()`, so only the user's wallet can authorise + * it. This module therefore never signs anything: it builds and simulates an + * unsigned transaction with the *user's* account as source, and later verifies + * that a wallet-signed envelope is exactly that transaction before relaying it. + * + * No secret key is read anywhere in this module. + */ + +import { + Address, + BASE_FEE, + Contract, + FeeBumpTransaction, + Networks, + Transaction, + TransactionBuilder, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { env } from "../config/env"; +import { ClaimError } from "./claimErrors"; + +export const CLAIM_FUNCTION = "claim_winnings"; +export const GET_CLAIM_NONCE_FUNCTION = "get_claim_nonce"; +/** How long a prepared transaction stays submittable. */ +export const CLAIM_TTL_SECONDS = 300; + +/** The subset of `rpc.Server` this module needs (eases faking in tests). */ +export type ClaimRpc = Pick< + rpc.Server, + "getAccount" | "simulateTransaction" | "sendTransaction" +>; + +export interface PreparedClaimTx { + xdr: string; + /** Hex hash of the unsigned transaction; a wallet signature does not change it. */ + hash: string; + networkPassphrase: string; + expiresAt: Date; +} + +export interface ExpectedClaimTx { + user: string; + marketSymbol: string; + nonce: bigint; + expectedTxHash: string; +} + +export interface SendResult { + status: string; + hash: string; + /** Base64 TransactionResult for ERROR responses (logs / code mapping only). */ + errorResultXdr?: string; +} + +export interface ClaimChain { + readonly networkPassphrase: string; + prepare(args: { + user: string; + marketSymbol: string; + nonce: bigint; + }): Promise; + /** Throws `claim_xdr_mismatch` unless `signedXdr` is exactly the prepared claim. */ + verifySigned(signedXdr: string, expected: ExpectedClaimTx): void; + send(signedXdr: string): Promise; +} + +export interface ClaimNonceSource { + getClaimNonce(args: { + user: string; + marketSymbol: string; + /** True when an earlier claim for this (user, market) may have consumed a nonce. */ + hasPriorClaim: boolean; + }): Promise; +} + +export function resolveNetworkPassphrase(): string { + return env.STELLAR_NETWORK === "testnet" ? Networks.TESTNET : Networks.PUBLIC; +} + +function claimArgs(user: string, marketSymbol: string, nonce: bigint): xdr.ScVal[] { + return [ + new Address(user).toScVal(), + nativeToScVal(marketSymbol, { type: "symbol" }), + nativeToScVal(nonce, { type: "u64" }), + ]; +} + +export class SorobanClaimChain implements ClaimChain { + readonly networkPassphrase: string; + private readonly contract: Contract; + + constructor( + private readonly server: ClaimRpc, + contractId: string = env.PREDICTIFY_CONTRACT_ID, + networkPassphrase: string = resolveNetworkPassphrase(), + ) { + this.contract = new Contract(contractId); + this.networkPassphrase = networkPassphrase; + } + + async prepare(args: { + user: string; + marketSymbol: string; + nonce: bigint; + }): Promise { + const { user, marketSymbol, nonce } = args; + try { + // The user — not the contract, not a server key — is the source. + const account = await this.server.getAccount(user); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + this.contract.call(CLAIM_FUNCTION, ...claimArgs(user, marketSymbol, nonce)), + ) + .setTimeout(CLAIM_TTL_SECONDS) + .build(); + + const sim = await this.server.simulateTransaction(tx); + if (!rpc.Api.isSimulationSuccess(sim)) { + throw new Error( + rpc.Api.isSimulationError(sim) + ? `simulation error: ${sim.error}` + : "simulation requires state restoration", + ); + } + + const assembled = rpc.assembleTransaction(tx, sim).build(); + const maxTime = Number(assembled.timeBounds?.maxTime ?? 0); + return { + xdr: assembled.toXDR(), + hash: assembled.hash().toString("hex"), + networkPassphrase: this.networkPassphrase, + expiresAt: new Date(maxTime * 1000), + }; + } catch (err) { + throw new ClaimError( + 422, + "claim_simulation_failed", + "Claim transaction could not be prepared", + err, + ); + } + } + + verifySigned(signedXdr: string, expected: ExpectedClaimTx): void { + const mismatch = (reason: string): ClaimError => + new ClaimError( + 400, + "claim_xdr_mismatch", + "Signed transaction does not match the prepared claim", + reason, + ); + + let parsed: Transaction | FeeBumpTransaction; + try { + parsed = TransactionBuilder.fromXDR(signedXdr, this.networkPassphrase); + } catch (err) { + throw new ClaimError( + 400, + "claim_xdr_mismatch", + "Signed transaction does not match the prepared claim", + err, + ); + } + const tx: Transaction = + parsed instanceof FeeBumpTransaction ? parsed.innerTransaction : parsed; + + if (tx.source !== expected.user) throw mismatch("source account"); + if (tx.signatures.length === 0) throw mismatch("transaction is not signed"); + if (tx.operations.length !== 1) throw mismatch("operation count"); + + const op = tx.operations[0]; + if (op.type !== "invokeHostFunction") throw mismatch("operation type"); + if (op.source !== undefined && op.source !== expected.user) { + throw mismatch("operation source"); + } + const fn = op.func; + if (fn.switch().name !== "hostFunctionTypeInvokeContract") { + throw mismatch("host function type"); + } + const invoke = fn.invokeContract(); + if (Address.fromScAddress(invoke.contractAddress()).toString() !== this.contract.contractId()) { + throw mismatch("contract id"); + } + if (invoke.functionName().toString() !== CLAIM_FUNCTION) { + throw mismatch("function name"); + } + const args = invoke.args(); + if (args.length !== 3) throw mismatch("argument count"); + try { + const [user, market, nonce] = args.map((a) => scValToNative(a)); + if (args[0].switch().name !== "scvAddress" || user !== expected.user) { + throw mismatch("user argument"); + } + if (args[1].switch().name !== "scvSymbol" || market !== expected.marketSymbol) { + throw mismatch("market_id argument"); + } + if (args[2].switch().name !== "scvU64" || BigInt(nonce) !== expected.nonce) { + throw mismatch("claim_nonce argument"); + } + } catch (err) { + if (err instanceof ClaimError) throw err; + throw mismatch("argument decoding"); + } + + // The authority: a wallet signature does not alter the transaction hash, + // so the inner hash must equal the one computed at prepare time. + if (tx.hash().toString("hex") !== expected.expectedTxHash) { + throw mismatch("transaction hash"); + } + } + + async send(signedXdr: string): Promise { + const tx = TransactionBuilder.fromXDR(signedXdr, this.networkPassphrase); + const resp = await this.server.sendTransaction(tx); + return { + status: resp.status, + hash: resp.hash, + errorResultXdr: resp.errorResult?.toXDR("base64"), + }; + } +} + +/** + * Default nonce source: simulate `get_claim_nonce(user, market_id)` when the + * deployed contract exports it (the slim testnet build does not). Otherwise + * `0` is only correct for a first claim, so any earlier claim yields + * `claim_nonce_unavailable` rather than a guess. + */ +export class SimulatedClaimNonceSource implements ClaimNonceSource { + private readonly contract: Contract; + + constructor( + private readonly server: ClaimRpc, + contractId: string = env.PREDICTIFY_CONTRACT_ID, + private readonly networkPassphrase: string = resolveNetworkPassphrase(), + ) { + this.contract = new Contract(contractId); + } + + async getClaimNonce(args: { + user: string; + marketSymbol: string; + hasPriorClaim: boolean; + }): Promise { + const fromContract = await this.simulate(args.user, args.marketSymbol); + if (fromContract !== null) return fromContract; + if (!args.hasPriorClaim) return 0n; + throw new ClaimError( + 409, + "claim_nonce_unavailable", + "Claim nonce could not be determined for this market", + ); + } + + private async simulate(user: string, marketSymbol: string): Promise { + try { + const account = await this.server.getAccount(user); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + this.contract.call( + GET_CLAIM_NONCE_FUNCTION, + new Address(user).toScVal(), + nativeToScVal(marketSymbol, { type: "symbol" }), + ), + ) + .setTimeout(CLAIM_TTL_SECONDS) + .build(); + const sim = await this.server.simulateTransaction(tx); + if (!rpc.Api.isSimulationSuccess(sim) || !sim.result) return null; + const value = scValToNative(sim.result.retval); + return typeof value === "bigint" || typeof value === "number" + ? BigInt(value) + : null; + } catch { + return null; + } + } +} + +/** Maps an RPC ERROR TransactionResult to a stable code (no SDK text). */ +export function mapSendErrorCode(errorResultXdr: string | undefined): "claim_expired" | "claim_rejected" { + if (!errorResultXdr) return "claim_rejected"; + try { + const result = xdr.TransactionResult.fromXDR(errorResultXdr, "base64"); + return result.result().switch().name === "txTooLate" ? "claim_expired" : "claim_rejected"; + } catch { + return "claim_rejected"; + } +} diff --git a/src/services/claimErrors.ts b/src/services/claimErrors.ts new file mode 100644 index 00000000..bbab8118 --- /dev/null +++ b/src/services/claimErrors.ts @@ -0,0 +1,43 @@ +/** + * claimErrors.ts + * + * Domain error for the claim flow. Messages are fixed, client-safe strings; + * the underlying RPC / SDK error (if any) travels in `detail` so callers can + * log it with the correlation id but never serialise it into a response. + */ + +export type ClaimErrorCode = + | "market_not_found" + | "market_not_resolved" + | "prediction_not_found" + | "prediction_not_winning" + | "ambiguous_prediction" + | "claim_not_found" + | "claim_already_submitted" + | "claim_in_progress" + | "claim_simulation_failed" + | "claim_rejected" + | "claim_expired" + | "claim_xdr_mismatch" + | "claim_nonce_unavailable" + | "claim_rpc_unavailable"; + +/** + * Thrown by the claim service for business-rule violations and mapped chain + * failures. Caught by the route handler and translated into the standard + * error envelope. + */ +export class ClaimError extends Error { + public readonly status: number; + public readonly code: string; + /** Raw underlying error for structured logs only — never sent to clients. */ + public readonly detail?: unknown; + + constructor(status: number, code: string, message: string, detail?: unknown) { + super(message); + this.name = "ClaimError"; + this.status = status; + this.code = code; + this.detail = detail; + } +} diff --git a/src/services/claimRepo.ts b/src/services/claimRepo.ts new file mode 100644 index 00000000..37743a51 --- /dev/null +++ b/src/services/claimRepo.ts @@ -0,0 +1,164 @@ +/** + * claimRepo.ts — Drizzle implementation of the claim persistence port. + */ + +import { and, eq, gt, inArray } from "drizzle-orm"; +import { db } from "../db"; +import { claims, markets, predictions } from "../db/schema"; +import type { + ClaimContext, + ClaimRecord, + ClaimRepo, + ClaimStatus, + NewPreparedClaim, + TransitionPatch, +} from "./claimService"; + +const UNIQUE_VIOLATION = "23505"; + +function isUniqueViolation(err: unknown): boolean { + const e = err as { code?: string; cause?: { code?: string } }; + return e?.code === UNIQUE_VIOLATION || e?.cause?.code === UNIQUE_VIOLATION; +} + +type ClaimRow = typeof claims.$inferSelect; + +function toRecord(row: ClaimRow): ClaimRecord { + return { + id: row.id, + userId: row.userId, + marketId: row.marketId, + amount: row.amount, + status: row.status as ClaimStatus, + settlementTx: row.settlementTx, + claimNonce: row.claimNonce, + expectedTxHash: row.expectedTxHash, + preparedXdr: row.preparedXdr, + expiresAt: row.expiresAt, + errorCode: row.errorCode, + createdAt: row.createdAt, + }; +} + +export class DrizzleClaimRepo implements ClaimRepo { + async loadContext(marketId: string, userId: string): Promise { + const [market] = await db + .select({ status: markets.status }) + .from(markets) + .where(eq(markets.id, marketId)) + .limit(1); + // Only confirmed rows are claimable. Fetch up to two so duplicates are + // detected deterministically instead of guessing with an unordered limit(1). + const confirmed = await db + .select({ + id: predictions.id, + result: predictions.result, + amount: predictions.amount, + claimTxHash: predictions.claimTxHash, + claimedAt: predictions.claimedAt, + }) + .from(predictions) + .where( + and( + eq(predictions.marketId, marketId), + eq(predictions.userId, userId), + eq(predictions.status, "confirmed"), + ), + ) + .orderBy(predictions.id) + .limit(2); + if (confirmed.length > 1) { + return { market: market ?? null, prediction: null, ambiguous: true }; + } + if (confirmed.length === 0) { + const [any] = await db + .select({ id: predictions.id }) + .from(predictions) + .where(and(eq(predictions.marketId, marketId), eq(predictions.userId, userId))) + .orderBy(predictions.id) + .limit(1); + return { market: market ?? null, prediction: null, unconfirmedOnly: Boolean(any) }; + } + return { market: market ?? null, prediction: confirmed[0] }; + } + + async findActiveClaim(userId: string, marketId: string): Promise { + const [row] = await db + .select() + .from(claims) + .where( + and( + eq(claims.userId, userId), + eq(claims.marketId, marketId), + inArray(claims.status, ["prepared", "submitting", "paid", "settled"]), + ), + ) + .limit(1); + return row ? toRecord(row) : null; + } + + async hasConsumedNonce(userId: string, marketId: string): Promise { + const [row] = await db + .select({ id: claims.id }) + .from(claims) + .where( + and( + eq(claims.userId, userId), + eq(claims.marketId, marketId), + inArray(claims.status, ["paid", "settled"]), + ), + ) + .limit(1); + return Boolean(row); + } + + async insertPrepared(input: NewPreparedClaim): Promise { + try { + const [row] = await db + .insert(claims) + .values({ ...input, status: "prepared" }) + .returning(); + return toRecord(row); + } catch (err) { + if (isUniqueViolation(err)) return null; + throw err; + } + } + + async findById(id: string): Promise { + const [row] = await db.select().from(claims).where(eq(claims.id, id)).limit(1); + return row ? toRecord(row) : null; + } + + async transition( + id: string, + from: ClaimStatus, + to: ClaimStatus, + patch: TransitionPatch = {}, + ): Promise { + const conditions = [eq(claims.id, id), eq(claims.status, from)]; + if (patch.notExpiredAt) conditions.push(gt(claims.expiresAt, patch.notExpiredAt)); + const [row] = await db + .update(claims) + .set({ + status: to, + ...(patch.settlementTx !== undefined && { settlementTx: patch.settlementTx }), + ...(patch.errorCode !== undefined && { errorCode: patch.errorCode }), + }) + .where(and(...conditions)) + .returning(); + return row ? toRecord(row) : null; + } + + async recordPredictionClaim( + userId: string, + marketId: string, + txHash: string, + claimedAt: Date, + ): Promise { + await db + .update(predictions) + .set({ claimTxHash: txHash, claimedAt }) + .where(and(eq(predictions.userId, userId), eq(predictions.marketId, marketId))); + } +} diff --git a/src/services/claimService.ts b/src/services/claimService.ts index b9dad9ef..05afbd1a 100644 --- a/src/services/claimService.ts +++ b/src/services/claimService.ts @@ -1,129 +1,240 @@ /** * claimService.ts * - * Handles the on-chain claim flow for winning predictions. After a market is - * resolved, users call this endpoint to build, simulate, and submit a Soroban - * claim transaction that transfers their winnings to their Stellar account. + * User-signed claim flow for winning predictions. * - * Architecture - * ------------ - * The service is split into a public function (`claimWinnings`) and a - * repository interface (`ClaimTxBuilder`) so that the Soroban RPC interaction - * can be easily mocked in tests without a live network. + * `claim_winnings(user, market_id, claim_nonce)` calls `user.require_auth()`, + * so the server can never submit a claim on its own. The flow is two steps: * - * Idempotency - * ----------- - * If a prediction already has `claimTxHash` populated, the function returns the - * existing data without making any on-chain calls. The HTTP-layer - * Idempotency-Key middleware provides an additional safeguard against duplicate - * submissions. + * 1. prepare — validate the market / prediction, resolve `claim_nonce`, build + * and simulate an unsigned transaction whose source is the user, and store + * it as a `claims` row (`status = "prepared"`) with its expected hash. + * 2. submit — accept the wallet-signed XDR, verify it is exactly the + * prepared transaction, then relay it and record the result. + * + * State machine (`claims.status`) + * ------------------------------- + * prepared ──► submitting ──► paid ──► settled | rejected (settle-confirmer) + * │ │ + * │ └──► failed (RPC ERROR) + * └──► expired (submitted after expiresAt) + * + * Why hash equality is the authority: a wallet signature does not change a + * transaction's hash, so `inner tx hash == prepared hash` proves the signed + * envelope is byte-for-byte the transaction we simulated — same source, + * contract, function, arguments, fee, footprint and time bounds. The + * field-by-field checks in `ClaimChain.verifySigned` exist only to give clear + * error codes. + * + * Concurrency: `prepared → submitting` is a conditional UPDATE + * (`WHERE status = 'prepared'`), so concurrent submits of one claim cannot + * both reach the RPC. A partial unique index allows at most one non-terminal + * claim per (user, market). */ -import { and, eq } from "drizzle-orm"; -import { db } from "../db"; -import { markets, predictions } from "../db/schema"; -import { env } from "../config/env"; import { logger } from "../config/logger"; +import { env } from "../config/env"; +import { getRequestId } from "../lib/requestContext"; +import { claimsPreparedTotal, claimsSubmittedTotal } from "../metrics/registry"; +import { + SimulatedClaimNonceSource, + SorobanClaimChain, + mapSendErrorCode, + type ClaimChain, + type ClaimNonceSource, +} from "./claimChain"; +import { ClaimError } from "./claimErrors"; + +export { ClaimError } from "./claimErrors"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export interface ClaimInput { - /** Database UUID of the resolved market. */ +export type ClaimStatus = + | "prepared" + | "submitting" + | "paid" + | "failed" + | "expired" + | "settled" + | "rejected"; + +export interface ClaimRecord { + id: string; + userId: string; + marketId: string; + amount: string; + status: ClaimStatus; + settlementTx: string | null; + claimNonce: string | null; + expectedTxHash: string | null; + preparedXdr: string | null; + expiresAt: Date | null; + errorCode: string | null; + createdAt: Date; +} + +export interface ClaimContext { + market: { status: string } | null; + /** The user's single confirmed prediction for the market, if exactly one exists. */ + prediction: { + id: string; + result: string | null; + amount: string; + claimTxHash: string | null; + claimedAt: Date | null; + } | null; + /** More than one confirmed prediction exists for this user and market. */ + ambiguous?: boolean; + /** Predictions exist but none is confirmed (pending / failed). */ + unconfirmedOnly?: boolean; +} + +export interface NewPreparedClaim { + userId: string; marketId: string; - /** Database UUID of the authenticated user. */ + amount: string; + claimNonce: string; + expectedTxHash: string; + preparedXdr: string; + expiresAt: Date; +} + +export interface TransitionPatch { + settlementTx?: string; + errorCode?: string; + /** Only transition while `expiresAt` is still in the future. */ + notExpiredAt?: Date; +} + +/** Persistence port — a Drizzle implementation lives in `claimRepo.ts`. */ +export interface ClaimRepo { + loadContext(marketId: string, userId: string): Promise; + /** The user's non-failed claim for the market (prepared / submitting / paid / settled). */ + findActiveClaim(userId: string, marketId: string): Promise; + /** True if a paid / settled claim exists, i.e. a nonce may have been consumed on-chain. */ + hasConsumedNonce(userId: string, marketId: string): Promise; + /** Inserts a prepared claim; returns null when the one-active-claim index rejects it. */ + insertPrepared(input: NewPreparedClaim): Promise; + findById(id: string): Promise; + /** Atomic `UPDATE … WHERE id AND status = from`; returns null if the row was not in `from`. */ + transition( + id: string, + from: ClaimStatus, + to: ClaimStatus, + patch?: TransitionPatch, + ): Promise; + recordPredictionClaim( + userId: string, + marketId: string, + txHash: string, + claimedAt: Date, + ): Promise; +} + +export interface ClaimDeps { + repo: ClaimRepo; + chain: ClaimChain; + nonceSource: ClaimNonceSource; + now?: () => Date; +} + +export interface PrepareClaimInput { + /** On-chain market id; must be a valid Soroban Symbol. */ + marketId: string; + userId: string; + /** Stellar G-address of the authenticated user. */ + stellarAddress: string; +} + +export interface PrepareClaimResult { + claimId: string; + xdr: string; + networkPassphrase: string; + expiresAt: string; +} + +export interface SubmitClaimInput { + claimId: string; userId: string; - /** Stellar G-address of the user (from JWT sub claim). */ stellarAddress: string; + signedXdr: string; } -export interface ClaimResult { - /** Prediction row id. */ - predictionId: string; - /** "won" | "lost" | null */ +export interface SubmitClaimResult { + claimId: string; + status: string; + predictionId: string | null; result: string | null; - /** Soroban transaction hash, or null if the prediction was not a winner. */ claimTxHash: string | null; - /** ISO timestamp of when the claim was submitted (null if not claimed). */ claimedAt: string | null; } -/** - * Interface for the Soroban transaction builder — extracted so tests can - * provide a mock that never talks to a real RPC endpoint. - */ -export interface ClaimTxBuilder { - /** - * Builds, simulates, and submits a Soroban `claim(marketId, user)` call. - * - * @param marketId On-chain market identifier (primary key in `markets`). - * @param user Stellar G-address of the user claiming. - * @returns The on-chain transaction hash (hex-encoded string). - */ - submitClaimTx(marketId: string, user: string): Promise; +// --------------------------------------------------------------------------- +// Default dependencies +// --------------------------------------------------------------------------- + +let defaultDeps: ClaimDeps | undefined; + +async function getDefaultDeps(): Promise { + if (!defaultDeps) { + const { rpc } = await import("@stellar/stellar-sdk"); + const { DrizzleClaimRepo } = await import("./claimRepo"); + const server = new rpc.Server(env.SOROBAN_RPC_URL, { + allowHttp: env.SOROBAN_RPC_URL.startsWith("http://"), + }); + defaultDeps = { + repo: new DrizzleClaimRepo(), + chain: new SorobanClaimChain(server), + nonceSource: new SimulatedClaimNonceSource(server), + }; + } + return defaultDeps; } // --------------------------------------------------------------------------- -// Domain errors +// Helpers // --------------------------------------------------------------------------- -/** - * Thrown by `claimWinnings` for business-rule violations (e.g. market not - * resolved, prediction not a winner). Caught by the route handler and - * translated into the standard error envelope. - */ -export class ClaimError extends Error { - public readonly status: number; - public readonly code: string; - - constructor(status: number, code: string, message: string) { - super(message); - this.name = "ClaimError"; - this.status = status; - this.code = code; - } +function logTransition(claim: ClaimRecord, from: ClaimStatus, extra?: Record): void { + logger.info( + { reqId: getRequestId(), claimId: claim.id, from, to: claim.status, ...extra }, + "claim: state change", + ); +} + +function preparedResult(claim: ClaimRecord, chain: ClaimChain): PrepareClaimResult { + return { + claimId: claim.id, + xdr: claim.preparedXdr as string, + networkPassphrase: chain.networkPassphrase, + expiresAt: (claim.expiresAt as Date).toISOString(), + }; } // --------------------------------------------------------------------------- -// Main service function +// Prepare // --------------------------------------------------------------------------- /** - * Claims winnings for a winning prediction after market resolution. + * Validates the claim and returns an unsigned, simulated `claim_winnings` + * transaction for the user's wallet to sign. * - * Flow: - * 1. Validate the market exists and is in "resolved" status. - * 2. Look up the user's prediction for this market. - * 3. If already claimed (claimTxHash is set) → return existing data. - * 4. If the prediction result is not "won" → reject. - * 5. Build & submit the Soroban claim transaction. - * 6. Persist claimTxHash and claimedAt on the prediction row. - * 7. Return the updated result. - * - * @throws {ClaimError} When business rules are violated. - * @throws {Error} For unexpected failures (bubbles to global error handler). + * @throws {ClaimError} Business-rule violations and mapped chain failures. */ -export async function claimWinnings( - input: ClaimInput, - txBuilder?: ClaimTxBuilder, -): Promise { +export async function prepareClaim( + input: PrepareClaimInput, + deps?: ClaimDeps, +): Promise { + const { repo, chain, nonceSource, now = () => new Date() } = deps ?? (await getDefaultDeps()); const { marketId, userId, stellarAddress } = input; - // Use the production builder if none was injected (DI for testability). - const builder = txBuilder ?? new SorobanClaimTxBuilder(); - - // ── 1. Validate market ──────────────────────────────────────────────── - const [market] = await db - .select({ status: markets.status, winningOutcome: markets.winningOutcome }) - .from(markets) - .where(eq(markets.id, marketId)) - .limit(1); - - if (!market) { - throw new ClaimError(404, "market_not_found", "Market not found"); - } - + const { market, prediction, ambiguous, unconfirmedOnly } = await repo.loadContext( + marketId, + userId, + ); + if (!market) throw new ClaimError(404, "market_not_found", "Market not found"); if (market.status !== "resolved") { throw new ClaimError( 400, @@ -131,56 +242,15 @@ export async function claimWinnings( "Market must be resolved before claims can be processed", ); } - - // ── 2. Look up the user's prediction ────────────────────────────────── - // 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, - claimTxHash: predictions.claimTxHash, - claimedAt: predictions.claimedAt, - }) - .from(predictions) - .where( - and( - eq(predictions.marketId, marketId), - eq(predictions.userId, userId), - eq(predictions.status, "confirmed"), - ), - ) - .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", - ); + if (ambiguous) { 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) { + if (unconfirmedOnly) { throw new ClaimError( 400, "prediction_not_winning", @@ -193,24 +263,13 @@ export async function claimWinnings( "No prediction found for this user and market", ); } - - // ── 3. Already claimed → idempotent return ──────────────────────────── if (prediction.claimTxHash) { - logger.info( - { marketId, userId, claimTxHash: prediction.claimTxHash }, - "claim: already claimed — returning existing data", + throw new ClaimError( + 409, + "claim_already_submitted", + "Winnings for this market have already been claimed", ); - return { - predictionId: prediction.id, - result: prediction.result, - claimTxHash: prediction.claimTxHash, - claimedAt: prediction.claimedAt - ? prediction.claimedAt.toISOString() - : null, - }; } - - // ── 4. Validate prediction result ───────────────────────────────────── if (prediction.result !== "won") { throw new ClaimError( 400, @@ -219,119 +278,228 @@ export async function claimWinnings( ); } - // ── 5. Submit Soroban claim transaction ─────────────────────────────── - let txHash: string; + // At most one non-terminal claim per (user, market). + const existing = await repo.findActiveClaim(userId, marketId); + if (existing) { + if (existing.status === "prepared") { + if (existing.expiresAt && existing.expiresAt.getTime() > now().getTime()) { + return preparedResult(existing, chain); + } + const expired = await repo.transition(existing.id, "prepared", "expired", { + errorCode: "claim_expired", + }); + if (expired) logTransition(expired, "prepared"); + } else if (existing.status === "submitting") { + throw new ClaimError(409, "claim_in_progress", "A claim is already being submitted"); + } else { + throw new ClaimError( + 409, + "claim_already_submitted", + "Winnings for this market have already been claimed", + ); + } + } + + const nonce = await nonceSource.getClaimNonce({ + user: stellarAddress, + marketSymbol: marketId, + hasPriorClaim: await repo.hasConsumedNonce(userId, marketId), + }); + + let prepared; try { - txHash = await builder.submitClaimTx(marketId, stellarAddress); + prepared = await chain.prepare({ user: stellarAddress, marketSymbol: marketId, nonce }); } catch (err) { - logger.error( - { err, marketId, userId }, - "claim: Soroban transaction submission failed", - ); - throw new ClaimError( - 500, - "claim_tx_failed", - `Failed to submit claim transaction: ${(err as Error).message}`, - ); + if (err instanceof ClaimError) { + logger.error( + { reqId: getRequestId(), marketId, userId, code: err.code, err: err.detail ?? err }, + "claim: prepare failed", + ); + } + throw err; } - // ── 6. Persist claim tx hash ────────────────────────────────────────── - const now = new Date(); - await db - .update(predictions) - .set({ claimTxHash: txHash, claimedAt: now }) - .where(eq(predictions.id, prediction.id)); + const inserted = await repo.insertPrepared({ + userId, + marketId, + amount: prediction.amount, + claimNonce: nonce.toString(), + expectedTxHash: prepared.hash, + preparedXdr: prepared.xdr, + expiresAt: prepared.expiresAt, + }); + if (!inserted) { + // Lost a race with a concurrent prepare: hand back the winner's claim. + const winner = await repo.findActiveClaim(userId, marketId); + if (winner?.status === "prepared") return preparedResult(winner, chain); + throw new ClaimError(409, "claim_in_progress", "A claim is already being submitted"); + } + claimsPreparedTotal.inc(); logger.info( - { marketId, userId, claimTxHash: txHash }, - "claim: winnings claimed successfully", + { reqId: getRequestId(), claimId: inserted.id, marketId, userId, nonce: inserted.claimNonce }, + "claim: prepared", ); + return preparedResult(inserted, chain); +} +// --------------------------------------------------------------------------- +// Submit +// --------------------------------------------------------------------------- + +async function toSubmitResult( + claim: ClaimRecord, + deps: ClaimDeps, +): Promise { + const { prediction } = await deps.repo.loadContext(claim.marketId, claim.userId); return { - predictionId: prediction.id, - result: prediction.result, - claimTxHash: txHash, - claimedAt: now.toISOString(), + claimId: claim.id, + status: claim.status, + predictionId: prediction?.id ?? null, + result: prediction?.result ?? null, + claimTxHash: claim.settlementTx, + claimedAt: prediction?.claimedAt?.toISOString() ?? null, }; } -// --------------------------------------------------------------------------- -// Production Soroban transaction builder -// --------------------------------------------------------------------------- +/** Maps a claim that cannot be submitted (again) to its stored result or a 409. */ +async function resolveNonSubmittable( + claim: ClaimRecord, + deps: ClaimDeps, +): Promise { + switch (claim.status) { + case "paid": + case "settled": + return toSubmitResult(claim, deps); + case "expired": + throw new ClaimError(409, "claim_expired", "Claim has expired; prepare a new one"); + case "failed": + case "rejected": + throw new ClaimError( + 409, + claim.errorCode ?? "claim_rejected", + "Claim was rejected; prepare a new one", + ); + default: + throw new ClaimError(409, "claim_in_progress", "Claim is already being submitted"); + } +} /** - * Builds and submits a Soroban claim transaction using `@stellar/stellar-sdk`. - * - * Under the hood: - * 1. Connects to the configured Soroban-RPC endpoint. - * 2. Constructs a `claim( marketId: string, user: address )` contract invocation. - * 3. Simulates the transaction to obtain the footprint + fee. - * 4. Assembles the final transaction and submits it. + * Verifies a wallet-signed claim transaction against the prepared one and + * relays it to the network. * - * NOTE: In a production setting the signing key-pair would be managed by a - * secure key-management service (e.g. HashiCorp Vault, AWS KMS). For this MVP - * the server must be configured with a Stellar secret key that has sufficient - * XLM balance to cover transaction fees. + * @throws {ClaimError} Business-rule violations and mapped chain failures. */ -export class SorobanClaimTxBuilder implements ClaimTxBuilder { - async submitClaimTx(marketId: string, user: string): Promise { - const { SorobanRpc, Contract, nativeToScVal, TransactionBuilder, Networks, BASE_FEE } = await import( - "@stellar/stellar-sdk" - ); - - const server = new SorobanRpc.Server(env.SOROBAN_RPC_URL); - const contract = new Contract(env.PREDICTIFY_CONTRACT_ID); - - // Build the Soroban invocation for claim(marketId, user). - // The contract function is expected to be: `fn claim(market_id: String, user: Address)` - const invocation = contract.call( - "claim", - nativeToScVal(marketId, { type: "string" }), - nativeToScVal(user, { type: "address" }), - ); +export async function submitClaim( + input: SubmitClaimInput, + deps?: ClaimDeps, +): Promise { + const resolved = deps ?? (await getDefaultDeps()); + const { repo, chain, now = () => new Date() } = resolved; + const { claimId, userId, stellarAddress, signedXdr } = input; + + const claim = await repo.findById(claimId); + if (!claim || claim.userId !== userId) { + throw new ClaimError(404, "claim_not_found", "Claim not found"); + } + if (claim.status !== "prepared") return resolveNonSubmittable(claim, resolved); + + if (!claim.expiresAt || claim.expiresAt.getTime() <= now().getTime()) { + const expired = await repo.transition(claim.id, "prepared", "expired", { + errorCode: "claim_expired", + }); + if (expired) { + logTransition(expired, "prepared"); + claimsSubmittedTotal.inc({ outcome: "expired" }); + } + throw new ClaimError(409, "claim_expired", "Claim has expired; prepare a new one"); + } - // ── Simulate the transaction ─────────────────────────────────────── - const account = await server.getAccount(env.PREDICTIFY_CONTRACT_ID); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: - env.STELLAR_NETWORK === "testnet" - ? Networks.TESTNET - : Networks.PUBLIC, - }) - .addOperation(invocation) - .setTimeout(30) - .build(); - - const simulateResp = await server.simulateTransaction(tx); - - if (SorobanRpc.Api.isSimulationError(simulateResp)) { - throw new Error( - `Soroban simulation failed: ${simulateResp.error ?? "unknown error"}`, + try { + chain.verifySigned(signedXdr, { + user: stellarAddress, + marketSymbol: claim.marketId, + nonce: BigInt(claim.claimNonce ?? "0"), + expectedTxHash: claim.expectedTxHash ?? "", + }); + } catch (err) { + claimsSubmittedTotal.inc({ outcome: "mismatch" }); + if (err instanceof ClaimError) { + logger.warn( + { reqId: getRequestId(), claimId, userId, reason: err.detail }, + "claim: signed XDR rejected", ); } + throw err; + } - // ── Assemble & submit ────────────────────────────────────────────── - // In production the fee-bump / signer key would come from a secure store. - // Here we assume the server has a funded account for fees. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const preparedTx: any = SorobanRpc.assembleTransaction(tx, simulateResp); - - // Submit without additional signature (the contract account pays fees). - // If a server-side signer is configured, sign here: - // const serverKeypair = Keypair.fromSecret(env.SERVER_SIGNER_SECRET); - // preparedTx.sign(serverKeypair); - - const sendResp = await server.sendTransaction(preparedTx); - - if (sendResp.status === "PENDING" || sendResp.status === "DUPLICATE") { - return sendResp.hash; + // Only one concurrent submit can win this conditional transition. + const submitting = await repo.transition(claim.id, "prepared", "submitting", { + notExpiredAt: now(), + }); + if (!submitting) { + const current = await repo.findById(claim.id); + if (current && current.status === "prepared") { + throw new ClaimError(409, "claim_expired", "Claim has expired; prepare a new one"); } + return resolveNonSubmittable(current ?? claim, resolved); + } + logTransition(submitting, "prepared"); - throw new Error( - `Soroban transaction rejected: ${sendResp.errorResult?.toString() ?? "unknown error"}`, + let sent; + try { + sent = await chain.send(signedXdr); + } catch (err) { + // Outcome unknown. Re-submitting the same signed tx is safe (same hash, + // same sequence number), so return the claim to `prepared`. + logger.error( + { reqId: getRequestId(), claimId, userId, err }, + "claim: sendTransaction threw", ); + const reverted = await repo.transition(claim.id, "submitting", "prepared"); + if (reverted) logTransition(reverted, "submitting"); + claimsSubmittedTotal.inc({ outcome: "error" }); + throw new ClaimError(503, "claim_rpc_unavailable", "Network unavailable; please retry"); } -} + if (sent.status === "PENDING" || sent.status === "DUPLICATE") { + const paid = await repo.transition(claim.id, "submitting", "paid", { + settlementTx: sent.hash, + }); + const claimedAt = now(); + await repo.recordPredictionClaim(userId, claim.marketId, sent.hash, claimedAt); + claimsSubmittedTotal.inc({ outcome: "paid" }); + if (paid) logTransition(paid, "submitting", { settlementTx: sent.hash }); + return toSubmitResult(paid ?? { ...submitting, status: "paid", settlementTx: sent.hash }, resolved); + } + + if (sent.status === "TRY_AGAIN_LATER") { + const reverted = await repo.transition(claim.id, "submitting", "prepared"); + if (reverted) logTransition(reverted, "submitting"); + claimsSubmittedTotal.inc({ outcome: "error" }); + throw new ClaimError(503, "claim_rpc_unavailable", "Network busy; please retry"); + } + + const code = mapSendErrorCode(sent.errorResultXdr); + const failed = await repo.transition(claim.id, "submitting", "failed", { errorCode: code }); + logger.error( + { + reqId: getRequestId(), + claimId, + userId, + rpcStatus: sent.status, + errorResultXdr: sent.errorResultXdr, + }, + "claim: transaction rejected by network", + ); + if (failed) logTransition(failed, "submitting", { errorCode: code }); + claimsSubmittedTotal.inc({ outcome: "failed" }); + throw new ClaimError( + code === "claim_expired" ? 409 : 422, + code, + code === "claim_expired" + ? "Claim has expired; prepare a new one" + : "Claim transaction was rejected by the network", + ); +} diff --git a/src/workers/settleConfirmer.ts b/src/workers/settleConfirmer.ts index b17810d5..155960f4 100644 --- a/src/workers/settleConfirmer.ts +++ b/src/workers/settleConfirmer.ts @@ -13,7 +13,7 @@ import { // ─── Drizzle repository ─────────────────────────────────────────────────────── -class DrizzleSettleConfirmerRepo implements SettleConfirmerRepo { +export class DrizzleSettleConfirmerRepo implements SettleConfirmerRepo { private readonly db: ReturnType; constructor(db: ReturnType) { diff --git a/tests/claimService.prepareSubmit.test.ts b/tests/claimService.prepareSubmit.test.ts new file mode 100644 index 00000000..c5807e01 --- /dev/null +++ b/tests/claimService.prepareSubmit.test.ts @@ -0,0 +1,828 @@ +import { readdirSync, readFileSync } from "fs"; +import { join } from "path"; +import { + Account, + Address, + Keypair, + Networks, + SorobanDataBuilder, + Transaction, + TransactionBuilder, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { logger } from "../src/config/logger"; +import { + SimulatedClaimNonceSource, + SorobanClaimChain, + mapSendErrorCode, + type ClaimNonceSource, + type ClaimRpc, +} from "../src/services/claimChain"; +import { + ClaimError, + prepareClaim, + submitClaim, + type ClaimContext, + type ClaimDeps, + type ClaimRecord, + type ClaimRepo, + type ClaimStatus, + type NewPreparedClaim, + type TransitionPatch, +} from "../src/services/claimService"; + +const CONTRACT_ID = "CCWRJXIUBSMFQ4AF2RXXH3AFBW6MEZG4GK4IYES7C4Z3X4PR6K7VAFHC"; +const PASSPHRASE = Networks.TESTNET; +const MARKET = "mkt_5cae97f9_0"; +const USER_ID = "00000000-0000-4000-8000-000000000001"; + +const wallet = Keypair.random(); +const USER = wallet.publicKey(); + +// ── Fakes ────────────────────────────────────────────────────────────────── + +function invokeOf(tx: Transaction) { + const op = tx.operations[0]; + if (op.type !== "invokeHostFunction") throw new Error("not an invoke op"); + return op.func.invokeContract(); +} + +class FakeRpc implements ClaimRpc { + simulated: Transaction[] = []; + sent: Transaction[] = []; + simulateError: string | null = null; + /** When set, `get_claim_nonce` simulates to this value; otherwise it errors (slim build). */ + nonceValue: bigint | null = null; + sendStatus = "PENDING"; + sendError: Error | null = null; + sendErrorResult: xdr.TransactionResult | undefined; + sendDelayMs = 0; + + async getAccount(address: string) { + return new Account(address, "100"); + } + + async simulateTransaction(tx: Transaction) { + this.simulated.push(tx); + const fn = invokeOf(tx).functionName().toString(); + if (fn === "get_claim_nonce") { + if (this.nonceValue === null) { + return { id: "1", latestLedger: 1, events: [], _parsed: true, error: "no such function" }; + } + return this.success( + xdr.ScVal.scvU64(xdr.Uint64.fromString(this.nonceValue.toString())), + ); + } + if (this.simulateError) { + return { id: "1", latestLedger: 1, events: [], _parsed: true, error: this.simulateError }; + } + return this.success(xdr.ScVal.scvVoid()); + } + + private success(retval: xdr.ScVal) { + return { + id: "1", + latestLedger: 1, + events: [], + _parsed: true, + transactionData: new SorobanDataBuilder(), + minResourceFee: "100", + cost: { cpuInsns: "0", memBytes: "0" }, + result: { auth: [], retval }, + }; + } + + async sendTransaction(tx: Transaction) { + this.sent.push(tx); + if (this.sendDelayMs) await new Promise((r) => setTimeout(r, this.sendDelayMs)); + if (this.sendError) throw this.sendError; + return { + status: this.sendStatus, + hash: tx.hash().toString("hex"), + latestLedger: 1, + latestLedgerCloseTime: 1, + errorResult: this.sendErrorResult, + }; + } +} + +class MemoryClaimRepo implements ClaimRepo { + rows: ClaimRecord[] = []; + context: ClaimContext = { + market: { status: "resolved" }, + prediction: { + id: "pred-1", + result: "won", + amount: "100", + claimTxHash: null, + claimedAt: null, + }, + }; + predictionClaims: Array<{ txHash: string }> = []; + private seq = 0; + + async loadContext() { + return this.context; + } + + async findActiveClaim(userId: string, marketId: string) { + return ( + this.rows.find( + (r) => + r.userId === userId && + r.marketId === marketId && + ["prepared", "submitting", "paid", "settled"].includes(r.status), + ) ?? null + ); + } + + async hasConsumedNonce(userId: string, marketId: string) { + return this.rows.some( + (r) => + r.userId === userId && + r.marketId === marketId && + ["paid", "settled"].includes(r.status), + ); + } + + async insertPrepared(input: NewPreparedClaim) { + // Mirrors the partial unique index on (user_id, market_id). + if ( + this.rows.some( + (r) => + r.userId === input.userId && + r.marketId === input.marketId && + ["prepared", "submitting", "paid"].includes(r.status), + ) + ) { + return null; + } + const row: ClaimRecord = { + id: `00000000-0000-4000-8000-${String(++this.seq).padStart(12, "0")}`, + ...input, + status: "prepared", + settlementTx: null, + errorCode: null, + createdAt: new Date(), + }; + this.rows.push(row); + return { ...row }; + } + + async findById(id: string) { + const row = this.rows.find((r) => r.id === id); + return row ? { ...row } : null; + } + + async transition(id: string, from: ClaimStatus, to: ClaimStatus, patch: TransitionPatch = {}) { + const row = this.rows.find((r) => r.id === id); + if (!row || row.status !== from) return null; + if (patch.notExpiredAt && !(row.expiresAt && row.expiresAt > patch.notExpiredAt)) { + return null; + } + row.status = to; + if (patch.settlementTx !== undefined) row.settlementTx = patch.settlementTx; + if (patch.errorCode !== undefined) row.errorCode = patch.errorCode; + return { ...row }; + } + + async recordPredictionClaim(_u: string, _m: string, txHash: string, claimedAt: Date) { + this.predictionClaims.push({ txHash }); + if (this.context.prediction) { + this.context.prediction.claimTxHash = txHash; + this.context.prediction.claimedAt = claimedAt; + } + } +} + +function setup(overrides: Partial = {}) { + const rpc = new FakeRpc(); + const repo = new MemoryClaimRepo(); + const chain = new SorobanClaimChain(rpc, CONTRACT_ID, PASSPHRASE); + const nonceSource = new SimulatedClaimNonceSource(rpc, CONTRACT_ID, PASSPHRASE); + const deps: ClaimDeps = { repo, chain, nonceSource, ...overrides }; + return { rpc, repo, deps }; +} + +function sign(xdrString: string, signer: Keypair = wallet): string { + const tx = TransactionBuilder.fromXDR(xdrString, PASSPHRASE) as Transaction; + tx.sign(signer); + return tx.toXDR(); +} + +const prepareInput = { marketId: MARKET, userId: USER_ID, stellarAddress: USER }; + +async function prepared(deps: ClaimDeps) { + return prepareClaim(prepareInput, deps); +} + +function submitInput(claimId: string, signedXdr: string) { + return { claimId, signedXdr, userId: USER_ID, stellarAddress: USER }; +} + +/** Builds a signed claim_winnings tx from scratch with arbitrary parameters. */ +function forgeSigned(opts: { + source?: string; + contract?: string; + fn?: string; + market?: string; + nonce?: bigint; + signer?: Keypair; +}): string { + const { Contract, nativeToScVal } = require("@stellar/stellar-sdk"); + const source = opts.source ?? USER; + const tx = new TransactionBuilder(new Account(source, "100"), { + fee: "100", + networkPassphrase: PASSPHRASE, + }) + .addOperation( + new Contract(opts.contract ?? CONTRACT_ID).call( + opts.fn ?? "claim_winnings", + new Address(USER).toScVal(), + nativeToScVal(opts.market ?? MARKET, { type: "symbol" }), + nativeToScVal(opts.nonce ?? 0n, { type: "u64" }), + ), + ) + .setTimeout(300) + .build(); + tx.sign(opts.signer ?? wallet); + return tx.toXDR(); +} + +let loggerError: jest.SpyInstance; +beforeEach(() => { + loggerError = jest.spyOn(logger, "error").mockImplementation(() => undefined); + jest.spyOn(logger, "info").mockImplementation(() => undefined); + jest.spyOn(logger, "warn").mockImplementation(() => undefined); +}); +afterEach(() => jest.restoreAllMocks()); + +async function expectClaimError(p: Promise, status: number, code: string) { + await expect(p).rejects.toMatchObject({ status, code }); + await p.catch((e) => expect(e).toBeInstanceOf(ClaimError)); +} + +// ── 1 ────────────────────────────────────────────────────────────────────── + +describe("prepareClaim", () => { + it("1: builds claim_winnings(user, Symbol(marketId), u64 0) with the user as source", async () => { + const { rpc, repo, deps } = setup(); + const out = await prepared(deps); + + // The fake RPC saw the real call: name, argument types and values. + const simulatedClaim = rpc.simulated.find( + (t) => invokeOf(t).functionName().toString() === "claim_winnings", + ) as Transaction; + const args = invokeOf(simulatedClaim).args(); + expect(args.map((a) => a.switch().name)).toEqual(["scvAddress", "scvSymbol", "scvU64"]); + + // The returned XDR decodes to the same call. + const tx = TransactionBuilder.fromXDR(out.xdr, PASSPHRASE) as Transaction; + expect(tx.source).toBe(USER); + expect(tx.signatures).toHaveLength(0); + const invoke = invokeOf(tx); + expect(invoke.functionName().toString()).toBe("claim_winnings"); + expect(Address.fromScAddress(invoke.contractAddress()).toString()).toBe(CONTRACT_ID); + const [user, market, nonce] = invoke.args().map((a) => scValToNative(a)); + expect(user).toBe(USER); + expect(market).toBe(MARKET); + expect(nonce).toBe(0n); + + expect(out.networkPassphrase).toBe(PASSPHRASE); + expect(out.claimId).toBe(repo.rows[0].id); + expect(new Date(out.expiresAt).getTime()).toBeGreaterThan(Date.now()); + expect(repo.rows[0]).toMatchObject({ + status: "prepared", + claimNonce: "0", + expectedTxHash: tx.hash().toString("hex"), + }); + }); + + it("2a: rejects an unresolved market and writes no claims row", async () => { + const { repo, deps } = setup(); + repo.context.market = { status: "open" }; + await expectClaimError(prepared(deps), 400, "market_not_resolved"); + expect(repo.rows).toHaveLength(0); + }); + + it("2b: rejects a losing prediction and writes no claims row", async () => { + const { repo, deps } = setup(); + repo.context.prediction!.result = "lost"; + await expectClaimError(prepared(deps), 400, "prediction_not_winning"); + expect(repo.rows).toHaveLength(0); + }); + + it("rejects an unknown market / missing prediction / already claimed prediction", async () => { + const a = setup(); + a.repo.context.market = null; + await expectClaimError(prepared(a.deps), 404, "market_not_found"); + + const b = setup(); + b.repo.context.prediction = null; + await expectClaimError(prepared(b.deps), 404, "prediction_not_found"); + + const c = setup(); + c.repo.context.prediction!.claimTxHash = "abc"; + await expectClaimError(prepared(c.deps), 409, "claim_already_submitted"); + }); + + it("2c: only confirmed predictions are claimable (ambiguous / unconfirmed)", async () => { + const a = setup(); + a.repo.context = { ...a.repo.context, prediction: null, ambiguous: true }; + await expectClaimError(prepared(a.deps), 409, "ambiguous_prediction"); + expect(a.repo.rows).toHaveLength(0); + + const b = setup(); + b.repo.context = { ...b.repo.context, prediction: null, unconfirmedOnly: true }; + await expectClaimError(prepared(b.deps), 400, "prediction_not_winning"); + expect(b.repo.rows).toHaveLength(0); + }); + + it("7a: maps a simulation error to a stable code without leaking SDK text", async () => { + const { rpc, repo, deps } = setup(); + rpc.simulateError = "HostError: secret internal detail"; + const err = await prepared(deps).catch((e) => e); + + expect(err).toBeInstanceOf(ClaimError); + expect(err.code).toBe("claim_simulation_failed"); + expect(JSON.stringify({ message: err.message, code: err.code })).not.toMatch( + /secret internal detail/, + ); + // The full error is kept for structured logs. + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ code: "claim_simulation_failed" }), + "claim: prepare failed", + ); + expect(String(loggerError.mock.calls[0][0].err)).toMatch(/secret internal detail/); + expect(repo.rows).toHaveLength(0); + }); + + it("8: a second prepare returns the existing prepared claim, never a second row", async () => { + const { repo, deps } = setup(); + const first = await prepared(deps); + const second = await prepared(deps); + expect(second.claimId).toBe(first.claimId); + expect(second.xdr).toBe(first.xdr); + expect(repo.rows).toHaveLength(1); + }); + + it("8b: concurrent prepares converge on one claim", async () => { + const { repo, deps } = setup(); + const [a, b] = await Promise.all([prepared(deps), prepared(deps)]); + expect(a.claimId).toBe(b.claimId); + expect(repo.rows).toHaveLength(1); + }); + + it("an expired prepared claim is expired and replaced by a fresh one", async () => { + let now = new Date(); + const { repo, deps } = setup({ now: () => now }); + const first = await prepared(deps); + now = new Date(now.getTime() + 10 * 60 * 1000); + const second = await prepared(deps); + expect(second.claimId).not.toBe(first.claimId); + expect(repo.rows.find((r) => r.id === first.claimId)).toMatchObject({ + status: "expired", + errorCode: "claim_expired", + }); + }); + + it("409s while a claim is submitting, and once it is paid", async () => { + const { repo, deps } = setup(); + const out = await prepared(deps); + repo.rows[0].status = "submitting"; + await expectClaimError(prepared(deps), 409, "claim_in_progress"); + repo.rows[0].status = "paid"; + await expectClaimError(prepared(deps), 409, "claim_already_submitted"); + expect(out.claimId).toBe(repo.rows[0].id); + }); + + it("uses the contract's get_claim_nonce when it is exported", async () => { + const { rpc, repo, deps } = setup(); + repo.rows.push({ + id: "old", + userId: USER_ID, + marketId: MARKET, + amount: "1", + status: "settled", + settlementTx: "x", + claimNonce: "0", + expectedTxHash: "x", + preparedXdr: "x", + expiresAt: new Date(), + errorCode: null, + createdAt: new Date(), + }); + rpc.nonceValue = 7n; + // A settled prior claim blocks a new prepare at the active-claim check, + // so exercise the nonce source directly. + await expect( + deps.nonceSource.getClaimNonce({ user: USER, marketSymbol: MARKET, hasPriorClaim: true }), + ).resolves.toBe(7n); + }); + + it("returns claim_nonce_unavailable when get_claim_nonce is missing and a prior claim exists", async () => { + const { deps } = setup(); + await expectClaimError( + deps.nonceSource.getClaimNonce({ user: USER, marketSymbol: MARKET, hasPriorClaim: true }), + 409, + "claim_nonce_unavailable", + ); + }); + + it("accepts an injected ClaimNonceSource", async () => { + const nonceSource: ClaimNonceSource = { getClaimNonce: jest.fn().mockResolvedValue(3n) }; + const { repo, deps } = setup({ nonceSource }); + await prepared(deps); + expect(repo.rows[0].claimNonce).toBe("3"); + expect(nonceSource.getClaimNonce).toHaveBeenCalledWith({ + user: USER, + marketSymbol: MARKET, + hasPriorClaim: false, + }); + }); +}); + +// ── submit ───────────────────────────────────────────────────────────────── + +describe("submitClaim", () => { + it("3: relays a correctly signed prepared XDR and records paid + settlement_tx", async () => { + const { rpc, repo, deps } = setup(); + const p = await prepared(deps); + const signed = sign(p.xdr); + + const out = await submitClaim(submitInput(p.claimId, signed), deps); + + expect(rpc.sent).toHaveLength(1); + expect(repo.rows[0].status).toBe("paid"); + expect(repo.rows[0].settlementTx).toBe(repo.rows[0].expectedTxHash); + expect(out).toMatchObject({ + claimId: p.claimId, + status: "paid", + claimTxHash: repo.rows[0].expectedTxHash, + predictionId: "pred-1", + }); + expect(repo.predictionClaims).toHaveLength(1); + }); + + it("3b: DUPLICATE from the RPC also moves the claim to paid", async () => { + const { rpc, repo, deps } = setup(); + rpc.sendStatus = "DUPLICATE"; + const p = await prepared(deps); + await submitClaim(submitInput(p.claimId, sign(p.xdr)), deps); + expect(repo.rows[0].status).toBe("paid"); + }); + + describe("4: any deviation from the prepared tx is claim_xdr_mismatch and never reaches the RPC", () => { + const cases: Array<[string, Parameters[0]]> = [ + ["market_id", { market: "other_market" }], + ["nonce", { nonce: 5n }], + ["contract", { contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" }], + ["source", { source: Keypair.random().publicKey() }], + ["function name", { fn: "claim_winnings_batch" }], + ]; + it.each(cases)("%s", async (_name, forged) => { + const { rpc, repo, deps } = setup(); + const p = await prepared(deps); + await expectClaimError( + submitClaim(submitInput(p.claimId, forgeSigned(forged)), deps), + 400, + "claim_xdr_mismatch", + ); + expect(rpc.sent).toHaveLength(0); + expect(repo.rows[0].status).toBe("prepared"); + }); + + it("an unsigned prepared XDR", async () => { + const { rpc, deps } = setup(); + const p = await prepared(deps); + await expectClaimError( + submitClaim(submitInput(p.claimId, p.xdr), deps), + 400, + "claim_xdr_mismatch", + ); + expect(rpc.sent).toHaveLength(0); + }); + + it("a tx identical in every field but with a different hash (fee)", async () => { + const { rpc, deps } = setup(); + const p = await prepared(deps); + const tx = TransactionBuilder.fromXDR(p.xdr, PASSPHRASE) as Transaction; + const tampered = TransactionBuilder.cloneFrom(tx, { + fee: "99999999", + networkPassphrase: PASSPHRASE, + }).build(); + tampered.sign(wallet); + await expectClaimError( + submitClaim(submitInput(p.claimId, tampered.toXDR()), deps), + 400, + "claim_xdr_mismatch", + ); + expect(rpc.sent).toHaveLength(0); + }); + + it("garbage XDR", async () => { + const { rpc, deps } = setup(); + const p = await prepared(deps); + await expectClaimError( + submitClaim(submitInput(p.claimId, "AAAAAAAA"), deps), + 400, + "claim_xdr_mismatch", + ); + expect(rpc.sent).toHaveLength(0); + }); + + it("a tx for a different network passphrase", async () => { + const { rpc, deps } = setup(); + const p = await prepared(deps); + const tx = TransactionBuilder.fromXDR(p.xdr, PASSPHRASE) as Transaction; + const other = TransactionBuilder.cloneFrom(tx, { + networkPassphrase: Networks.PUBLIC, + fee: tx.fee, + }).build(); + other.sign(wallet); + await expectClaimError( + submitClaim(submitInput(p.claimId, other.toXDR()), deps), + 400, + "claim_xdr_mismatch", + ); + expect(rpc.sent).toHaveLength(0); + }); + + it("a fee-bump wrapper is unwrapped and its inner tx verified", async () => { + const { rpc, repo, deps } = setup(); + const p = await prepared(deps); + const inner = TransactionBuilder.fromXDR(sign(p.xdr), PASSPHRASE) as Transaction; + const sponsor = Keypair.random(); + const bump = TransactionBuilder.buildFeeBumpTransaction( + sponsor, + "1000000", + inner, + PASSPHRASE, + ); + bump.sign(sponsor); + await submitClaim(submitInput(p.claimId, bump.toXDR()), deps); + expect(rpc.sent).toHaveLength(1); + expect(repo.rows[0].status).toBe("paid"); + }); + }); + + it("5: concurrent submits of one claim hit the RPC exactly once", async () => { + const { rpc, repo, deps } = setup(); + rpc.sendDelayMs = 20; + const p = await prepared(deps); + const signed = sign(p.xdr); + + const results = await Promise.allSettled([ + submitClaim(submitInput(p.claimId, signed), deps), + submitClaim(submitInput(p.claimId, signed), deps), + submitClaim(submitInput(p.claimId, signed), deps), + ]); + + expect(rpc.sent).toHaveLength(1); + expect(repo.rows).toHaveLength(1); + expect(repo.rows[0].status).toBe("paid"); + const fulfilled = results.filter((r) => r.status === "fulfilled"); + expect(fulfilled.length).toBeGreaterThanOrEqual(1); + for (const r of results) { + if (r.status === "rejected") { + expect(r.reason).toMatchObject({ status: 409, code: "claim_in_progress" }); + } + } + }); + + it("6: submitting after expiresAt is 409 claim_expired and skips the RPC", async () => { + let now = new Date(); + const { rpc, repo, deps } = setup({ now: () => now }); + const p = await prepared(deps); + const signed = sign(p.xdr); + now = new Date(new Date(p.expiresAt).getTime() + 1000); + + await expectClaimError( + submitClaim(submitInput(p.claimId, signed), deps), + 409, + "claim_expired", + ); + expect(rpc.sent).toHaveLength(0); + expect(repo.rows[0]).toMatchObject({ status: "expired", errorCode: "claim_expired" }); + + // Replaying an expired claim stays a 409. + await expectClaimError( + submitClaim(submitInput(p.claimId, signed), deps), + 409, + "claim_expired", + ); + }); + + it("7b: RPC ERROR status fails the claim with a stable code and no SDK text", async () => { + const { rpc, repo, deps } = setup(); + rpc.sendStatus = "ERROR"; + const p = await prepared(deps); + const err = await submitClaim(submitInput(p.claimId, sign(p.xdr)), deps).catch((e) => e); + + expect(err).toBeInstanceOf(ClaimError); + expect(err).toMatchObject({ status: 422, code: "claim_rejected" }); + expect(repo.rows[0]).toMatchObject({ status: "failed", errorCode: "claim_rejected" }); + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ claimId: p.claimId, rpcStatus: "ERROR" }), + "claim: transaction rejected by network", + ); + + // A failed claim is final: replays are 409. + await expectClaimError( + submitClaim(submitInput(p.claimId, sign(p.xdr)), deps), + 409, + "claim_rejected", + ); + // …and the user can prepare a fresh one. + rpc.sendStatus = "PENDING"; + const again = await prepared(deps); + expect(again.claimId).not.toBe(p.claimId); + }); + + it("maps a txTooLate RPC error to claim_expired", async () => { + const { rpc, repo, deps } = setup(); + rpc.sendStatus = "ERROR"; + rpc.sendErrorResult = new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString("100"), + result: xdr.TransactionResultResult.txTooLate(), + ext: new xdr.TransactionResultExt(0), + }); + const p = await prepared(deps); + await expectClaimError( + submitClaim(submitInput(p.claimId, sign(p.xdr)), deps), + 409, + "claim_expired", + ); + expect(repo.rows[0].errorCode).toBe("claim_expired"); + }); + + it("an RPC exception returns the claim to prepared and is a retryable 503", async () => { + const { rpc, repo, deps } = setup(); + rpc.sendError = new Error("socket hang up: internal-host:1234"); + const p = await prepared(deps); + const signed = sign(p.xdr); + const err = await submitClaim(submitInput(p.claimId, signed), deps).catch((e) => e); + + expect(err).toMatchObject({ status: 503, code: "claim_rpc_unavailable" }); + expect(err.message).not.toMatch(/internal-host/); + expect(repo.rows[0].status).toBe("prepared"); + + rpc.sendError = null; + await submitClaim(submitInput(p.claimId, signed), deps); + expect(repo.rows[0].status).toBe("paid"); + }); + + it("TRY_AGAIN_LATER returns the claim to prepared", async () => { + const { rpc, repo, deps } = setup(); + rpc.sendStatus = "TRY_AGAIN_LATER"; + const p = await prepared(deps); + await expectClaimError( + submitClaim(submitInput(p.claimId, sign(p.xdr)), deps), + 503, + "claim_rpc_unavailable", + ); + expect(repo.rows[0].status).toBe("prepared"); + }); + + it("9: replaying a submit for a paid claim returns the stored result with the same settlement_tx", async () => { + const { rpc, deps } = setup(); + const p = await prepared(deps); + const signed = sign(p.xdr); + const first = await submitClaim(submitInput(p.claimId, signed), deps); + const replay = await submitClaim(submitInput(p.claimId, signed), deps); + expect(replay.claimTxHash).toBe(first.claimTxHash); + expect(replay.status).toBe("paid"); + expect(rpc.sent).toHaveLength(1); + }); + + it("404s for an unknown claim or someone else's claim", async () => { + const { deps } = setup(); + const p = await prepared(deps); + await expectClaimError( + submitClaim(submitInput("00000000-0000-4000-8000-0000000000ff", sign(p.xdr)), deps), + 404, + "claim_not_found", + ); + await expectClaimError( + submitClaim( + { ...submitInput(p.claimId, sign(p.xdr)), userId: "00000000-0000-4000-8000-0000000000aa" }, + deps, + ), + 404, + "claim_not_found", + ); + }); + + it("a claim already moved to submitting by another request is 409 claim_in_progress", async () => { + const { repo, deps } = setup(); + const p = await prepared(deps); + repo.rows[0].status = "submitting"; + await expectClaimError( + submitClaim(submitInput(p.claimId, sign(p.xdr)), deps), + 409, + "claim_in_progress", + ); + }); + + it("a claim that expires between the check and the transition is claim_expired", async () => { + const { rpc, repo, deps } = setup(); + const p = await prepared(deps); + const signed = sign(p.xdr); + // Expiry passes only once the conditional transition runs. + const realTransition = repo.transition.bind(repo); + jest.spyOn(repo, "transition").mockImplementation(async (id, from, to, patch) => { + if (to === "submitting") repo.rows[0].expiresAt = new Date(Date.now() - 1000); + return realTransition(id, from, to, patch); + }); + await expectClaimError( + submitClaim(submitInput(p.claimId, signed), deps), + 409, + "claim_expired", + ); + expect(rpc.sent).toHaveLength(0); + }); +}); + +describe("mapSendErrorCode", () => { + it("defaults to claim_rejected for missing or undecodable results", () => { + expect(mapSendErrorCode(undefined)).toBe("claim_rejected"); + expect(mapSendErrorCode("not-xdr")).toBe("claim_rejected"); + }); +}); + +// ── 10 ───────────────────────────────────────────────────────────────────── + +describe("10: the server never signs", () => { + const claimModules = [ + "src/services/claimService.ts", + "src/services/claimChain.ts", + "src/services/claimErrors.ts", + "src/services/claimRepo.ts", + ]; + + it.each(claimModules)("%s does not import Keypair or read a secret key", (file) => { + const source = readFileSync(join(__dirname, "..", file), "utf8"); + expect(source).not.toMatch(/\bKeypair\b/); + expect(source).not.toMatch(/fromSecret|SECRET/); + expect(source).not.toMatch(/\.sign\(/); + }); + + it("the claim route module does not reference signing either", () => { + const source = readFileSync(join(__dirname, "..", "src/routes/predictions.ts"), "utf8"); + expect(source).not.toMatch(/\bKeypair\b/); + }); + + it("covers every src/services/claim*.ts module", () => { + const onDisk = readdirSync(join(__dirname, "..", "src/services")) + .filter((f) => /^claim.*\.ts$/.test(f)) + .map((f) => `src/services/${f}`) + .sort(); + expect(onDisk).toEqual([...claimModules].sort()); + }); +}); + +// ── 11 ───────────────────────────────────────────────────────────────────── + +describe("11: the settle-confirmer picks up claims written by submitClaim", () => { + it("selects status = 'paid' rows with a settlement_tx, which is what a successful submit writes", async () => { + const { drizzle } = await import("drizzle-orm/node-postgres"); + const { DrizzleSettleConfirmerRepo } = await import("../src/workers/settleConfirmer"); + + // Paid row exactly as submitClaim leaves it. + const { rpc, repo, deps } = setup(); + const p = await prepared(deps); + await submitClaim(submitInput(p.claimId, sign(p.xdr)), deps); + const paid = repo.rows[0]; + expect(paid.status).toBe("paid"); + expect(paid.settlementTx).toBeTruthy(); + expect(rpc.sent).toHaveLength(1); + + // Run the confirmer's real query through drizzle against a recording stub. + const queries: Array<{ text: string; values: unknown[] }> = []; + const client = { + query: async (config: { text: string; values?: unknown[] } | string, values?: unknown[]) => { + const text = typeof config === "string" ? config : config.text; + queries.push({ + text, + values: values ?? (typeof config === "string" ? [] : config.values) ?? [], + }); + return { + rows: [[paid.id, paid.settlementTx, 0]], + rowCount: 1, + fields: [], + command: "SELECT", + oid: 0, + }; + }, + }; + const confirmerRepo = new DrizzleSettleConfirmerRepo(drizzle(client as never)); + const pending = await confirmerRepo.getPendingSettlements(new Date()); + + expect(pending).toEqual([{ id: paid.id, settlementTx: paid.settlementTx, settleAttempts: 0 }]); + expect(queries).toHaveLength(1); + expect(queries[0].text).toMatch(/from "claims"/); + expect(queries[0].text).toMatch(/"status" = \$1/); + expect(queries[0].values).toContain(paid.status); + }); +}); diff --git a/tests/predictions-claim.test.ts b/tests/predictions-claim.test.ts index 6c998bab..d9b5cb12 100644 --- a/tests/predictions-claim.test.ts +++ b/tests/predictions-claim.test.ts @@ -9,7 +9,8 @@ jest.mock("../src/services/claimService", () => { const actual = jest.requireActual("../src/services/claimService"); return { ...actual, - claimWinnings: jest.fn(), + prepareClaim: jest.fn(), + submitClaim: jest.fn(), }; }); @@ -28,162 +29,189 @@ jest.mock("../src/middleware/requireAuth", () => ({ }, })); -// Helper to get the mocked function // eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockClaimWinnings: any = claimService.claimWinnings; +const mockPrepare: any = claimService.prepareClaim; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockSubmit: any = claimService.submitClaim; const app = createApp(); +const CLAIM_ID = "0b7a5d5e-3c1f-4e0a-9d51-6d9f9a1c2b11"; +const SIGNED_XDR = "AAAAAgAAAAA" + "A".repeat(40) + "=="; + +function postPrepare(body?: Record) { + return request(app) + .post("/api/predictions/claim/prepare") + .send(body ?? { marketId: "mkt_5cae97f9_0" }); +} + function postClaim(body?: Record) { return request(app) .post("/api/predictions/claim") - .send(body ?? { marketId: "mkt-1" }); + .send(body ?? { claimId: CLAIM_ID, signedXdr: SIGNED_XDR }); +} + +function err(status: number, code: string) { + return new claimService.ClaimError(status, code, `message for ${code}`); } beforeEach(() => { jest.clearAllMocks(); }); -// ── Tests ────────────────────────────────────────────────────────────────── - -describe("POST /api/predictions/claim", () => { - // ── Validation ──────────────────────────────────────────────────────── - - it("returns 400 for missing marketId", async () => { - const res = await postClaim({}); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("validation_error"); - }); - - it("returns 400 for empty marketId", async () => { - const res = await postClaim({ marketId: "" }); - +// ── POST /api/predictions/claim/prepare ─────────────────────────────────── + +describe("POST /api/predictions/claim/prepare", () => { + it.each([ + ["missing marketId", {}], + ["empty marketId", { marketId: "" }], + ["marketId longer than 32 characters", { marketId: "a".repeat(33) }], + ["marketId that is not a Soroban Symbol", { marketId: "mkt-1" }], + ["marketId that is a UUID", { marketId: "0b7a5d5e-3c1f-4e0a-9d51-6d9f9a1c2b11" }], + ["extra fields", { marketId: "mkt_1", extra: true }], + ])("returns 400 validation_error for %s", async (_name, body) => { + const res = await postPrepare(body as Record); expect(res.status).toBe(400); expect(res.body.error.code).toBe("validation_error"); + expect(mockPrepare).not.toHaveBeenCalled(); }); - it("rejects extra fields in the body", async () => { - const res = await postClaim({ - marketId: "mkt-1", - extraField: "should not be allowed", - }); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("validation_error"); + it("accepts a 32-character Symbol", async () => { + mockPrepare.mockResolvedValue({ claimId: CLAIM_ID }); + const res = await postPrepare({ marketId: "a".repeat(32) }); + expect(res.status).toBe(200); }); - // ── Business rules ──────────────────────────────────────────────────── - - it("returns 404 when market does not exist", async () => { - mockClaimWinnings.mockRejectedValue( - new claimService.ClaimError(404, "market_not_found", "Market not found"), - ); + it("returns 200 with { xdr, networkPassphrase, expiresAt, claimId }", async () => { + const data = { + claimId: CLAIM_ID, + xdr: "AAAA", + networkPassphrase: "Test SDF Network ; September 2015", + expiresAt: "2026-10-19T12:05:00.000Z", + }; + mockPrepare.mockResolvedValue(data); - const res = await postClaim(); + const res = await postPrepare(); - expect(res.status).toBe(404); - expect(res.body.error.code).toBe("market_not_found"); + expect(res.status).toBe(200); + expect(res.body.data).toEqual(data); + expect(mockPrepare).toHaveBeenCalledWith({ + marketId: "mkt_5cae97f9_0", + userId: "test-user-id", + stellarAddress: "GABCDEF...", + }); }); - it("returns 404 when user has no prediction in the market", async () => { - mockClaimWinnings.mockRejectedValue( - new claimService.ClaimError( - 404, - "prediction_not_found", - "No prediction found for this user and market", - ), - ); - - const res = await postClaim(); - - expect(res.status).toBe(404); - expect(res.body.error.code).toBe("prediction_not_found"); + it.each([ + [404, "market_not_found"], + [404, "prediction_not_found"], + [400, "market_not_resolved"], + [400, "prediction_not_winning"], + [409, "claim_in_progress"], + [409, "claim_already_submitted"], + [409, "claim_nonce_unavailable"], + [422, "claim_simulation_failed"], + ])("maps %i %s from the service", async (status, code) => { + mockPrepare.mockRejectedValue(err(status, code)); + const res = await postPrepare(); + expect(res.status).toBe(status); + expect(res.body.error.code).toBe(code); + expect(res.body.error.message).toBe(`message for ${code}`); }); - it("returns 400 when market is not resolved", async () => { - mockClaimWinnings.mockRejectedValue( - new claimService.ClaimError( - 400, - "market_not_resolved", - "Market must be resolved before claims can be processed", - ), - ); - - const res = await postClaim(); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("market_not_resolved"); + it("passes unexpected errors to the global handler (no raw message in the body)", async () => { + mockPrepare.mockRejectedValue(new Error("pg: connection refused 10.0.0.5")); + const res = await postPrepare(); + expect(res.status).toBe(500); + expect(JSON.stringify(res.body)).not.toContain("10.0.0.5"); }); +}); - it("returns 400 when prediction is not a winner", async () => { - mockClaimWinnings.mockRejectedValue( - new claimService.ClaimError( - 400, - "prediction_not_winning", - 'Prediction result is "lost". Only winning predictions can be claimed', - ), - ); - - const res = await postClaim(); +// ── POST /api/predictions/claim ─────────────────────────────────────────── +describe("POST /api/predictions/claim", () => { + it.each([ + ["missing body", {}], + ["missing signedXdr", { claimId: CLAIM_ID }], + ["missing claimId", { signedXdr: SIGNED_XDR }], + ["non-uuid claimId", { claimId: "nope", signedXdr: SIGNED_XDR }], + ["empty signedXdr", { claimId: CLAIM_ID, signedXdr: "" }], + ["non-base64 signedXdr", { claimId: CLAIM_ID, signedXdr: "not base64!!" }], + ["signedXdr over 16 KB", { claimId: CLAIM_ID, signedXdr: "A".repeat(16 * 1024 + 4) }], + ["the old marketId-only body", { marketId: "mkt_1" }], + ["extra fields", { claimId: CLAIM_ID, signedXdr: SIGNED_XDR, extra: 1 }], + ])("returns 400 validation_error for %s", async (_name, body) => { + const res = await postClaim(body as Record); expect(res.status).toBe(400); - expect(res.body.error.code).toBe("prediction_not_winning"); + expect(res.body.error.code).toBe("validation_error"); + expect(mockSubmit).not.toHaveBeenCalled(); }); - it("returns 500 when Soroban tx submission fails", async () => { - mockClaimWinnings.mockRejectedValue( - new claimService.ClaimError( - 500, - "claim_tx_failed", - "Failed to submit claim transaction: RPC error", - ), - ); - - const res = await postClaim(); - - expect(res.status).toBe(500); - expect(res.body.error.code).toBe("claim_tx_failed"); + it("accepts a signedXdr of exactly 16 KB", async () => { + mockSubmit.mockResolvedValue({ claimId: CLAIM_ID }); + const res = await postClaim({ claimId: CLAIM_ID, signedXdr: "A".repeat(16 * 1024) }); + expect(res.status).toBe(200); }); - // ── Success paths ───────────────────────────────────────────────────── - - it("returns 200 with claim result on successful claim", async () => { - const fakeResult: claimService.ClaimResult = { + it("returns 200 with the submit result", async () => { + const data = { + claimId: CLAIM_ID, + status: "paid", predictionId: "pred-1", result: "won", - claimTxHash: "0xdeadbeef1234567890abcdef", - claimedAt: "2025-01-01T00:00:00.000Z", + claimTxHash: "deadbeef", + claimedAt: "2026-10-19T12:00:00.000Z", }; - mockClaimWinnings.mockResolvedValue(fakeResult); + mockSubmit.mockResolvedValue(data); const res = await postClaim(); expect(res.status).toBe(200); - expect(res.body.data).toEqual(fakeResult); + expect(res.body.data).toEqual(data); + expect(mockSubmit).toHaveBeenCalledWith({ + claimId: CLAIM_ID, + signedXdr: SIGNED_XDR, + userId: "test-user-id", + stellarAddress: "GABCDEF...", + }); }); - it("returns 200 for already claimed prediction (idempotent)", async () => { - const fakeResult: claimService.ClaimResult = { - predictionId: "pred-1", - result: "won", - claimTxHash: "0xalreadyclaimed", - claimedAt: "2025-01-01T00:00:00.000Z", - }; - mockClaimWinnings.mockResolvedValue(fakeResult); + it("replaying a submit for a paid claim returns 200 with the same settlement tx", async () => { + const data = { claimId: CLAIM_ID, status: "paid", claimTxHash: "deadbeef" }; + mockSubmit.mockResolvedValue(data); - // First call — claim succeeds const res1 = await postClaim(); - expect(res1.status).toBe(200); - expect(res1.body.data.claimTxHash).toBe("0xalreadyclaimed"); - - // Second call — claim is idempotent; service returns same data const res2 = await postClaim(); + + expect(res1.status).toBe(200); expect(res2.status).toBe(200); - expect(res2.body.data.claimTxHash).toBe("0xalreadyclaimed"); + expect(res2.body.data.claimTxHash).toBe(res1.body.data.claimTxHash); + expect(mockSubmit).toHaveBeenCalledTimes(2); + }); - // Service was indeed called both times - expect(mockClaimWinnings).toHaveBeenCalledTimes(2); + it.each([ + [404, "claim_not_found"], + [400, "claim_xdr_mismatch"], + [409, "claim_expired"], + [409, "claim_in_progress"], + [409, "claim_rejected"], + [422, "claim_rejected"], + [503, "claim_rpc_unavailable"], + ])("maps %i %s from the service", async (status, code) => { + mockSubmit.mockRejectedValue(err(status, code)); + const res = await postClaim(); + expect(res.status).toBe(status); + expect(res.body.error.code).toBe(code); }); -}); + it("never echoes the underlying SDK error from ClaimError.detail", async () => { + mockSubmit.mockRejectedValue( + new claimService.ClaimError(422, "claim_rejected", "Claim transaction was rejected", { + sdk: "HostError: Error(Contract, #12) secret", + }), + ); + const res = await postClaim(); + expect(res.status).toBe(422); + expect(JSON.stringify(res.body)).not.toMatch(/HostError|secret/); + }); +});