Settlement & Claiming
A parlay settles one leg at a time. Legs are independent; the slip’s status is derived from them.
Parlay states
Section titled “Parlay states” ┌─────────┐ │ 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 │ └──────────────────┘| Status | Byte | Meaning | Claimable |
|---|---|---|---|
Active | 0 | At least one leg still pending | — |
Won | 1 | All legs terminal, no losses | yes — payout |
Lost | 2 | A leg lost; nothing to collect | no |
Claimed | 3 | Paid out; terminal | no |
Voided | 4 | Every leg’s market cancelled — set by settle_leg | yes — stake refund |
Refunded | 5 | void_parlay already paid the stake back; terminal | no |
Transitions are one-way, enforced by Anchor constraints on each
instruction.
Leg states
Section titled “Leg states” ┌──────────┐ │ Pending │ └────┬─────┘ │ settle_leg, market terminal ┌─────────────┼──────────────┐ ▼ ▼ ▼ ┌──────┐ ┌──────┐ ┌────────┐ │ Won │ │ Lost │ │ Voided │ └──────┘ └──────┘ └────────┘ resolved, resolved, market your side other side cancelledsettle_leg
Section titled “settle_leg”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 state | Leg result | Effect |
|---|---|---|
| Resolved, your side won | Won | legs_won += 1 |
| Resolved, your side lost | Lost | Slip → Lost, full exposure released |
| Cancelled | Voided | Payout 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:
| Condition | Slip becomes |
|---|---|
No leg lost, legs_won > 0 | Won |
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.
Why permissionless
Section titled “Why permissionless”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.
Validations
Section titled “Validations”parlay.status == Activeleg_index < parlay.legs.len()parlay.legs[leg_index].status == Pendingmarket.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 }.
Voided legs
Section titled “Voided legs”A cancelled market doesn’t kill the slip — the leg is removed from the product:
new_payout = old_payout × prob_i / PROB_SCALEA 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.
Losing
Section titled “Losing”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_payoutimmediately — 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 == Activeconstraint blocks them. Legs still markedPendingsimply stay that way.
claim_parlay
Section titled “claim_parlay”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| Status | Vault → user | Vault → treasury |
|---|---|---|
Won | net | fee |
Voided | stake | — |
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.
No fee on a refund
Section titled “No fee on a refund”A Voided slip pays back exactly the stake with no fee taken. The house
charges for winning, not for a market it cancelled.
The fee snapshot
Section titled “The fee snapshot”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.
The claim deadline
Section titled “The claim deadline”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.
void_parlay (admin)
Section titled “void_parlay (admin)”An admin recovery tool with two distinct cases. Both refund
parlay.stake and end in Refunded.
| Case | Requires | Rejected with |
|---|---|---|
| Unresolved slip | status == Active and no leg has resolved yet | ParlayHasResolvedLegs (6024) |
| Expired winner | status == Won and past parlay.claim_deadline | ClaimWindowNotElapsed (6026) |
Anything already terminal — Claimed, Voided, Refunded, Lost — is
rejected at the account level with ParlayNotVoidable (6027).
| Transfers | parlay.stake — not potential_payout |
| Destination | Constrained to parlay.user |
| Status | → Refunded |
| Exposure | Released by potential_payout (post-recompute) |
Two guarantees worth knowing
Section titled “Two guarantees worth knowing”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 }.
Timing summary
Section titled “Timing summary”| Event | Who | When |
|---|---|---|
| Create | User | Any time while is_paused == false |
| Settle a leg | Anyone | After the market is Resolved or Cancelled |
| Claim | Parlay owner | After the slip is Won |
| Admin void | Admin | Active with no leg resolved, or Won past its claim deadline |
| Seed vault | Admin | Any time |
| Withdraw vault | Admin | Two steps, 48h apart; capped at vault.amount − total_exposure |
| Update config | Admin | Any time; applies to future parlays only |
Indexed status
Section titled “Indexed status”The Platform API mirrors the on-chain status one-for-one:
| API status | On-chain | Set by | Claimable |
|---|---|---|---|
active | Active | create_parlay | — |
won | Won | settle_leg | yes — payout |
lost | Lost | settle_leg | no |
voided | Voided | settle_leg, all legs cancelled | yes — stake refund |
refunded | Refunded | void_parlay | no — already paid |
claimed | Claimed | claim_parlay | no |
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.