Skip to content

Settlement & Claiming

A parlay settles one leg at a time. Legs are independent; the slip’s status is derived from them.

┌─────────┐
│ Active │──────────────────┐
└────┬────┘ │
┌──────────────────────┼──────────────────┐ │ void_parlay
│ settle_leg │ settle_leg │ │ (admin)
│ (any leg lost) │ (last leg in) │ │
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────────┐ ┌──────────┐
│ Lost │ │ Won │ │ Voided │ │ Refunded │
└──────┘ └──┬───┘ └───┬────┘ └──────────┘
│ │ admin already
claim_parlay │ │ claim_parlay paid out
▼ ▼
┌──────────────────┐
│ Claimed │
└──────────────────┘
StatusByteMeaningClaimable
Active0At least one leg still pending—
Won1All legs terminal, no lossesyes — payout
Lost2A leg lost; nothing to collectno
Claimed3Paid out; terminalno
Voided4Every leg’s market cancelled — set by settle_legyes — stake refund
Refunded5void_parlay already paid the stake back; terminalno

Transitions are one-way, enforced by Anchor constraints on each instruction.

┌──────────┐
│ Pending │
└────┬─────┘
│ settle_leg, market terminal
┌─────────────┼──────────────┐
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────────┐
│ Won │ │ Lost │ │ Voided │
└──────┘ └──────┘ └────────┘
resolved, resolved, market
your side other side cancelled

Permissionless. Anyone can call it — the caller only pays the transaction fee.

settle_leg(leg_index: u8)
accounts: config (mut) · parlay (mut) · market (read-only)
Market stateLeg resultEffect
Resolved, your side wonWonlegs_won += 1
Resolved, your side lostLostSlip → Lost, full exposure released
CancelledVoidedPayout recomputed × prob_i / 1e6, exposure delta released
Open, Closed, PendingResolution, Disputed—Reverts with MarketNotResolved

After incrementing legs_resolved, once legs_resolved == legs_total the slip finalizes:

ConditionSlip becomes
No leg lost, legs_won > 0Won
No leg lost, legs_won == 0 (every leg voided)Voided, and potential_payout is reset to the original stake so the claim refunds exactly

That covers mixed won-and-voided slips: voided legs count toward legs_resolved but not legs_won, so a slip with one cancelled leg and two winners still settles Won at the recomputed multiplier.

The leg’s outcome is fully determined by on-chain market state. An adversary who settles early gains nothing — they cannot influence what the market resolved to by being the one who reads it. Making it open means users can settle their own legs the moment a market resolves, and operators or indexers can keep state fresh without holding a privileged key.

Idempotency comes from the per-leg status check: once a leg leaves Pending, a repeat settle fails with LegNotPending (6033). Retry loops are safe.

  • parlay.status == Active
  • leg_index < parlay.legs.len()
  • parlay.legs[leg_index].status == Pending
  • market.key() == parlay.legs[leg_index].market_address
  • The market account passes the same discriminator and layout check as create_parlay

Emits LegSettled { parlay_id, leg_index, market_id, won, voided }.

A cancelled market doesn’t kill the slip — the leg is removed from the product:

new_payout = old_payout × prob_i / PROB_SCALE

A 3-leg slip at 50/40/25 paying $200 (20×), where the 25% leg cancels, becomes a 2-leg slip paying $50 (5×). You keep an honest bet on the legs that still exist.

total_exposure drops by old_payout − new_payout. The truncation is biased toward the house.

→ Pricing & Payout Math

The moment any settle_leg writes Lost, the slip flips to Lost:

  • The stake stays in the vault.
  • Exposure is released by the full potential_payout immediately — the slip can no longer pay out, so the reservation is dead weight. Conservative in the vault’s favour.
  • No further settles fire; the status == Active constraint blocks them. Legs still marked Pending simply stay that way.

Signed by the parlay owner. Valid on Won or Voided — two different payouts from one instruction.

Won fee = min( floor(potential_payout × fee_bps_at_create / 10_000),
potential_payout )
net = potential_payout − fee
Voided net = stake fee = 0 // full refund, no fee
StatusVault → userVault → treasury
Wonnetfee
Voidedstake—

Validations: status == Won ∥ Voided, parlay.user == user.key(), vault.amount ≥ net + fee.

Status flips to Claimed in both cases and exposure is released. Emits ParlayClaimed { parlay_id, user, payout, fee }.

Note potential_payout on a Won slip is the recomputed value after any voided legs — a slip with a cancelled leg pays the smaller multiplier.

A Voided slip pays back exactly the stake with no fee taken. The house charges for winning, not for a market it cancelled.

fee_bps_at_create is written once, at create, and read at claim. An admin update_config in between cannot change what a winning user pays. This is invariant 3 of the program.

A won slip does not stay claimable forever. parlay.claim_deadline is written at create time from config.claim_deadline_secs (valid range: 1 hour to ~3 years). Once it passes, an admin may call void_parlay on the winner — the stake is refunded and the winnings are forfeited.

This exists because an unclaimed winner holds potential_payout of vault exposure indefinitely: capacity nobody can use, on money the user isn’t collecting. The recovery path releases it.

Before the deadline, void_parlay against a Won slip is rejected with ClaimWindowNotElapsed (6026).

Claim your winners promptly. This is the one way a winning parlay can pay less than it should.

An admin recovery tool with two distinct cases. Both refund parlay.stake and end in Refunded.

CaseRequiresRejected with
Unresolved slipstatus == Active and no leg has resolved yetParlayHasResolvedLegs (6024)
Expired winnerstatus == Won and past parlay.claim_deadlineClaimWindowNotElapsed (6026)

Anything already terminal — Claimed, Voided, Refunded, Lost — is rejected at the account level with ParlayNotVoidable (6027).

Transfersparlay.stake — not potential_payout
DestinationConstrained to parlay.user
Status→ Refunded
ExposureReleased by potential_payout (post-recompute)

An admin cannot void you out of upside. The moment any leg resolves, an Active slip stops being voidable. Earlier behaviour allowed it at any point while Active; the resolved-legs check closed that.

An admin cannot redirect a refund. The destination is bound twice — the account list checks user.key() == parlay.user, and the token account carries token::authority = user. That’s invariant 7.

Emits ParlayVoided { parlay_id, user, refund }.

EventWhoWhen
CreateUserAny time while is_paused == false
Settle a legAnyoneAfter the market is Resolved or Cancelled
ClaimParlay ownerAfter the slip is Won
Admin voidAdminActive with no leg resolved, or Won past its claim deadline
Seed vaultAdminAny time
Withdraw vaultAdminTwo steps, 48h apart; capped at vault.amount − total_exposure
Update configAdminAny time; applies to future parlays only

The Platform API mirrors the on-chain status one-for-one:

API statusOn-chainSet byClaimable
activeActivecreate_parlay—
wonWonsettle_legyes — payout
lostLostsettle_legno
voidedVoidedsettle_leg, all legs cancelledyes — stake refund
refundedRefundedvoid_parlayno — already paid
claimedClaimedclaim_parlayno

voided still owes the user a claim; refunded has already been settled. Getting these two backwards is the most common integration bug against this API.

→ Parlays API