Skip to content

Vault & Solvency

Parlays are house-banked. The counterparty to every slip is a USDC vault owned by the high-market-parlay program, and its solvency is enforced on-chain rather than monitored operationally.

Vault (token account) seeds = ["parlay_vault"]
ParlayConfig seeds = ["parlay_config"]
1. vault.amount ≥ config.total_exposure (always)
2. config.total_exposure + new_potential_payout
≤ 0.8 × (vault.amount + stake) (at create)

Invariant 1 says the vault can always pay everything it owes. Invariant 2 is how it stays true: a new slip is only accepted if it leaves a 20% buffer after reserving its full potential payout.

A slip that would breach invariant 2 is rejected with ExposureLimitExceeded (6011). Reduce the stake.

It’s not a general safety margin — it covers specific, bounded drift:

  • u64 truncation on potential_payout: at most 1 micro-USDC per parlay.
  • Cancelled-leg recompute rounding: integer division rounds down, which releases more exposure than is mathematically owed. Safe by construction, but it accumulates.

Every rounding decision in the program is biased toward the house. The buffer exists so that bias never needs to be trusted for exactness.

config.total_exposure is the sum of potential_payout over every open, unclaimed slip.

EventExposure
create_parlay+= potential_payout
Leg voided-= (old_payout − new_payout)
Slip goes Lost-= potential_payout — immediately
claim_parlay-= potential_payout
void_parlay-= potential_payout

Releasing the full exposure the instant a slip is Lost is deliberately conservative: the slip can never pay out again, so continuing to reserve against it would needlessly block new business.

Decrements are saturating, so an accounting slip can never wrap total_exposure into an enormous number.

┌──────────────────────── vault.amount ────────────────────────┐
│ │
│ ████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ total_exposure (reserved) free │
│ │
│ ├──────────── new slips allowed up to 80% ──────────┤ │
└──────────────────────────────────────────────────────────────┘

Two different limits use this picture:

  • New slips may push total_exposure up to 80% of the balance.
  • Admin withdrawals may take up to vault.amount − total_exposure — the whole free region, not just the 20% band.

withdraw_vault enforcing amount ≤ vault.amount − total_exposure preserves invariant 1 directly.

ParlayConfig is a global singleton created once by initialize.

FieldTypeMeaning
adminPubkeyAdmin authority (a Squads multisig in production)
vaultPubkeyThe parlay USDC vault PDA
treasuryPubkeyReceives fees at claim
collateral_mintPubkeyFrozen at init
amm_program_idPubkeyThe AMM program; compile-time pinned, immutable
fee_bpsu16Fee on gross payout
max_legsu8Legs per slip
min_stake / max_stakeu64Stake bounds, micro-USDC
max_payoutu64Hard cap on per-slip gross payout
total_parlaysu64Monotonic counter, used as parlay_id
total_exposureu64Sum of open potential_payout
is_pausedboolWhen true, create_parlay is rejected
prob_floor_bps / prob_ceiling_bpsu16The per-leg probability band, in bps of PROB_SCALE
min_market_age_secsi64How old a market must be to be a leg
min_market_bu64Minimum AMM liquidity parameter
min_market_volumeu64Minimum settled volume
claim_deadline_secsi64How long a won slip stays claimable
pending_admin / pending_admin_apply_atPubkey, i64Queued admin rotation
pending_withdraw_{amount,destination,execute_after}u64, Pubkey, i64Queued withdrawal
bump / vault_bumpu8PDA bumps

Note how much of the risk policy is configurable state rather than constants: the probability band, the market-quality floors and the claim window are all admin-tunable within hard bounds. That is what lets the risk posture be tightened without a program upgrade.

validate_params is called from both initialize and every update_config, so one helper owns the rules:

FieldRule
fee_bps≤ MAX_FEE_BPS
max_legsWithin the configured range
min_stake≤ max_stake
max_payout> 0
probability bandHARD_PROB_FLOOR ≤ floor < ceiling ≤ HARD_PROB_CEILING (9900 bps = 99%)
min_market_age_secs≥ TWAP_WINDOW_SECS — a market younger than one full window has nothing to average
claim_deadline_secsWithin [1 hour, ~3 years]

update_config takes every parameter as Option<T> and validates the post-update tuple before committing, so a single call cannot transit through a temporarily invalid state.

A won slip does not stay claimable forever. parlay.claim_deadline is written at create time from config.claim_deadline_secs, and 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, which is capacity no one can use and the user is not collecting. The recovery path releases it.

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

InstructionEffect
initializeOnce, at deploy. Pins the AMM program id; sets treasury, fee, bounds.
update_configMutate fee, max legs, stake bounds, max payout, paused, probability band, market-quality floors, claim deadline — and propose an admin rotation
accept_adminSigned by the incoming admin, after the timelock
cancel_admin_rotationCall off a pending rotation
update_treasuryRotate the fee destination (takes the account, not a pubkey)
seed_vaultTop up the vault from the admin’s USDC
propose_withdraw_vaultQueue a withdrawal; starts the timelock
cancel_withdraw_vaultAbort a queued withdrawal
withdraw_vaultExecute the queued withdrawal after the timelock
void_parlayRefund an unresolved Active slip, or recover an expired winner

All require config.admin to sign. In production that is a Squads multisig.

Privileged actions are two-step and timelocked

Section titled “Privileged actions are two-step and timelocked”

Both admin rotation and vault withdrawal take 48 hours in production (ADMIN_ROTATION_TIMELOCK_SECS, WITHDRAW_TIMELOCK_SECS):

admin rotation update_config(new_admin) ──48h──▶ accept_admin
│ (signed by the NEW admin)
└── cancel_admin_rotation
withdrawal propose_withdraw_vault ──48h──▶ withdraw_vault
│ (same amount + destination)
└── cancel_withdraw_vault

A stolen admin key therefore cannot rotate-and-drain, or drain in one transaction. There is a window for the real admin to notice and cancel, and promotion needs a signature from the incoming admin — so a single-key compromise cannot complete a rotation on its own.

Both queued actions are visible on-chain in ParlayConfig while their timelock runs, so a pending rotation or withdrawal is observable by anyone for the full 48 hours before it can execute.

  • Rotate amm_program_id. It is a compile-time constant checked at initialize, and absent from update_config. If an admin could change it, they could point the parlay at a malicious program whose Market accounts decode to attacker-chosen outcomes — the parlay reads that state directly, without CPI. Rotating requires deploying a new program version.
  • Redirect a refund. void_parlay’s destination is bound to parlay.user by both an explicit equality check and a token::authority = user constraint.
  • Void a slip the user has upside on. Once any leg has resolved, an Active slip can no longer be voided (ParlayHasResolvedLegs, 6024).
  • Void a winner inside its claim window (ClaimWindowNotElapsed, 6026).
  • Change a placed slip’s fee. fee_bps_at_create is snapshotted.
  • Drain reserved funds. withdraw_vault caps at the free balance.
  • Set a wrong-mint treasury. update_treasury takes the account, so the token::mint constraint rejects it at admin time rather than bricking the next claim_parlay.
  • Settle or claim for a user. settle_leg is permissionless but outcome-determined; claim_parlay requires the user’s signature.

update_config applies to future parlays only. Existing slips carry their own snapshot of everything that matters, including the claim deadline.

is_paused = true rejects create_parlay with Paused (6000). It does not block settling or claiming — existing slips continue to resolve and pay out normally.

That scoping is deliberate: pausing is for stopping new risk, not for freezing user funds.

Terminal window
# Current state
curl -s https://prod-api.gomarket.io/api/parlays/vault/
# Re-read the on-chain balance into the DB mirror (superuser)
curl -s -X POST https://prod-api.gomarket.io/api/parlays/vault/sync/ \
-H "Authorization: Bearer $ADMIN_JWT"
{
"data": {
"vault_address": "…",
"balance": 500000000,
"total_staked": 120000000,
"total_paid_out": 80000000,
"total_exposure": 210000000,
"max_payout_cap": 500000000,
"max_legs": 10,
"min_stake": 1000000,
"max_stake": 100000000,
"is_active": true
}
}

The API’s balance and total_exposure are a mirror, updated from webhooks and the sync endpoint. On-chain state is authoritative. Reconcile with vault/sync/ before acting on it.

headroom = 0.8 × vault.amount − total_exposure

When headroom approaches zero, large slips start failing with ExposureLimitExceeded while small ones still succeed. Users experience this as “my bet was rejected for no reason,” so it’s worth alerting on before it bites.

→ Risk Controls