Skip to content

fix(claims): user-signed claim_winnings prepare/submit flow - #993

Open
solaawojobi00-bit wants to merge 2 commits into
Predictify-org:mainfrom
solaawojobi00-bit:fix/issue-977-claim-prepare-submit
Open

solaawojobi00-bit wants to merge 2 commits into
Predictify-org:mainfrom
solaawojobi00-bit:fix/issue-977-claim-prepare-submit

Conversation

@solaawojobi00-bit

Copy link
Copy Markdown

fix(claims): user-signed claim_winnings prepare/submit flow

Problem

POST /api/predictions/claim could not work against the deployed contract. The backend tried to sign and submit the call itself, and claim_winnings calls user.require_auth(), so only the user's wallet can authorize it.

# Scenario on main Result
1 Entrypoint Calls "claim"; the contract exports claim_winnings
2 Arguments market_id sent as String (must be Symbol); claim_nonce missing
3 Auth Server-built tx can never satisfy user.require_auth()
4 Source account getAccount(PREDICTIFY_CONTRACT_ID) loads a contract id as a classic account and throws on every call
5 Concurrency claimTxHash read → submit → write with no lock; two requests both pass
6 Errors Raw RPC/SDK text returned to the client in claim_tx_failed
7 Settle-confirmer Nothing ever inserted into claims, so finality checks never ran

Solution

Two-step flow; the server never signs.

  1. POST /api/predictions/claim/prepare { marketId } validates the market and winning prediction, resolves claim_nonce (injectable ClaimNonceSource; default simulates get_claim_nonce, else 0 only when the user has no paid/settled claim, else claim_nonce_unavailable), builds and simulates claim_winnings(user, Symbol(marketId), u64 nonce) with the user as source, stores a claims row (prepared) and returns { xdr, networkPassphrase, expiresAt, claimId }.
  2. POST /api/predictions/claim { claimId, signedXdr } verifies the envelope, moves the row prepared → submitting with a conditional UPDATE, calls sendTransaction, and records paid (+ settlement_tx) or failed.

State machine: prepared → submitting → paid | failed, prepared → expired; the settle-confirmer finalizes paid rows (settled / rejected).

Why hash equality is the authority. A wallet signature does not change a transaction's hash. The prepared tx hash is stored at prepare time and a signed envelope is accepted only if its inner tx hash is identical, so it is byte-for-byte the transaction we simulated (source, contract, function, args, fee, footprint, time bounds). The field-by-field checks (source, contract, function name, each arg, signed) run first only to return a precise claim_xdr_mismatch; they do not replace the hash check.

Changes

  • src/services/claimChain.ts (new): SorobanClaimChain builds/simulates/assembles the unsigned tx, verifySigned does the exact-match checks, send relays. SimulatedClaimNonceSource is the default nonce source. No secret key is read anywhere.
  • src/services/claimService.ts: claimWinnings / SorobanClaimTxBuilder removed; replaced by prepareClaim / submitClaim with an injectable ClaimRepo/ClaimChain. Existing market/prediction checks are reused.
  • src/services/claimRepo.ts (new): Drizzle implementation; transition() is UPDATE … WHERE id AND status = from, which is what keeps concurrent submits to a single RPC call.
  • src/services/claimErrors.ts (new): ClaimError with fixed messages; the raw error lives in detail and goes only to logs (with the request correlation id).
  • src/routes/predictions.ts: /claim/prepare and the new /claim body. marketId must be a Soroban Symbol (≤32 chars, [A-Za-z0-9_]); signedXdr must be base64, ≤16 KB.
  • src/db/schema.ts + drizzle/0027_claims_prepare_submit.sql + journal: new claims columns (claim_nonce, expected_tx_hash, prepared_xdr, expires_at, error_code) and partial unique index claims_one_active_per_user_market_idx on (user_id, market_id) WHERE status IN ('prepared','submitting','paid').
  • src/metrics/registry.ts: claims_prepared_total, claims_submitted_total{outcome}.
  • src/openapi/registry.ts, openapi.yaml, scripts/check-openapi.ts: both operations, schemas and error codes; new route added to the expected-routes list.
  • src/workers/settleConfirmer.ts: DrizzleSettleConfirmerRepo exported (one word) so a test can run its real query.
  • docs/predictions-api.md: wallet-signing sequence, states, idempotency, error codes.

Regression Tests

# Scenario Test Result
1 Prepare → claim_winnings(user, Symbol, 0), user as source prepareClaim › 1: pass
2 Not resolved / losing prediction, no row written 2a, 2b pass
3 Signed XDR → paid + settlement_tx submitClaim › 3:, 3b pass
4 Different market / nonce / contract / source (+ fn name, unsigned, fee tamper, other network, garbage) → 400 claim_xdr_mismatch, RPC never called 4: group (9 cases) pass
5 Concurrent submits → exactly one RPC call 5: pass
6 Submit after expiresAt → 409 claim_expired, no RPC 6: pass
7 Simulation error / RPC ERROR → stable code, no SDK text in body, present in log 7a, 7b pass
8 Second prepare → same claimId, one row 8, 8b pass
9 Replay of a paid claim → 200, same settlement_tx 9: pass
10 Keypair not importable from claim modules 10: (static) pass
11 Settle-confirmer query returns the paid row 11: pass

Regression proof (row 1). Against the original claimService.ts, with a fake RPC asserting the call, the old builder invokes claim with [scvString, scvAddress] (and loads the contract id as the account); the test expecting claim_winnings(scvAddress, scvSymbol, scvU64) fails. It passes after this change.

Testing

$ npx jest tests/claimService.prepareSubmit.test.ts --coverage ...
Tests:       41 passed, 41 total   (+1 row-11 test added afterwards, passing)
 claimService.ts | 94.26 % stmts | 83.56 % branch | 93.69 % lines

$ npx jest tests/predictions-claim.test.ts
Tests:       37 passed, 37 total

$ npm run openapi:generate && npm run openapi:check
OK: All routes, reproducible OpenAPI artifact, and contract invariants validated successfully.

$ grep -n '"claim"' src/services/claimService.ts   -> no matches

Read-only simulation of the production SorobanClaimChain against testnet contract CCWRJXIUBSMFQ4AF2RXXH3AFBW6MEZG4GK4IYES7C4Z3X4PR6K7VAFHC with a throwaway friendbot-funded wallet (no winnings):

claim_nonce resolved: 0
prepare -> claim_simulation_failed
  HostError: Error(Contract, #104)   topics: [fn_call, CCWR…, claim_winnings]

The contract executed claim_winnings with our arguments and answered with a business error (#104), not a function-name or argument-type error.

Notes for Reviewers

  • Testnet success transcript not included. A signed submit needs a wallet with real winnings in a resolved market, which I don't have. Only the read-only simulation above is real.
  • Migration is hand-written (same style as 0026). drizzle-kit generate currently does nothing on main because drizzle/meta/0002_snapshot.json and 0003_snapshot.json are malformed, so db:generate / db:check-drift cannot produce or detect a migration. I did not touch those snapshots. CI's db:migrate applies the SQL; I could not run it locally (no Postgres).
  • No DB-backed tests. Persistence is behind a ClaimRepo port; unit tests use an in-memory repo with the same atomic-transition semantics. The real partial unique index and conditional UPDATE are exercised only by CI/production.
  • Judgment calls: the non-terminal set for the unique index includes paid (a paid claim awaiting settlement must block a second one). "Previous claim" for the nonce fallback means a paid/settled claim, so a failed or expired attempt does not block a retry. An RPC exception or TRY_AGAIN_LATER returns the claim to prepared (re-sending the same signed tx is safe) with 503 claim_rpc_unavailable; this code is an addition to the issue's list. claims.amount is populated from the prediction's stake as a placeholder; the real payout is decided on-chain.
  • tsc and npm test are not clean on main locally in my environment (no Postgres/Redis); I fixed only what this change touches. npm run lint reports one error in src/lib/http.ts, which this PR does not touch.

Closes #977

Replace the server-submitted claim with a prepare/sign/submit flow that matches claim_winnings(user, market_id, claim_nonce). The server never signs; a signed XDR is accepted only if its inner tx hash equals the prepared hash.
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@solaawojobi00-bit Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

…-prepare-submit

# Conflicts:
#	openapi.yaml
#	src/openapi/registry.ts
#	src/services/claimService.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rebuild winnings claims as a user-signed prepare/submit flow that matches claim_winnings(user, market_id, claim_nonce)

1 participant