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"]The two invariants
Section titled “The two invariants”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.
What the buffer absorbs
Section titled “What the buffer absorbs”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.
Exposure accounting
Section titled “Exposure accounting”config.total_exposure is the sum of potential_payout over every open,
unclaimed slip.
| Event | Exposure |
|---|---|
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.
Reserved vs. free
Section titled “Reserved vs. free”┌──────────────────────── vault.amount ────────────────────────┐│ ││ ████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ││ total_exposure (reserved) free ││ ││ ├──────────── new slips allowed up to 80% ──────────┤ │└──────────────────────────────────────────────────────────────┘Two different limits use this picture:
- New slips may push
total_exposureup 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.
The config
Section titled “The config”ParlayConfig is a global singleton created once by initialize.
| Field | Type | Meaning |
|---|---|---|
admin | Pubkey | Admin authority (a Squads multisig in production) |
vault | Pubkey | The parlay USDC vault PDA |
treasury | Pubkey | Receives fees at claim |
collateral_mint | Pubkey | Frozen at init |
amm_program_id | Pubkey | The AMM program; compile-time pinned, immutable |
fee_bps | u16 | Fee on gross payout |
max_legs | u8 | Legs per slip |
min_stake / max_stake | u64 | Stake bounds, micro-USDC |
max_payout | u64 | Hard cap on per-slip gross payout |
total_parlays | u64 | Monotonic counter, used as parlay_id |
total_exposure | u64 | Sum of open potential_payout |
is_paused | bool | When true, create_parlay is rejected |
prob_floor_bps / prob_ceiling_bps | u16 | The per-leg probability band, in bps of PROB_SCALE |
min_market_age_secs | i64 | How old a market must be to be a leg |
min_market_b | u64 | Minimum AMM liquidity parameter |
min_market_volume | u64 | Minimum settled volume |
claim_deadline_secs | i64 | How long a won slip stays claimable |
pending_admin / pending_admin_apply_at | Pubkey, i64 | Queued admin rotation |
pending_withdraw_{amount,destination,execute_after} | u64, Pubkey, i64 | Queued withdrawal |
bump / vault_bump | u8 | PDA 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.
Bounds, re-checked on every write
Section titled “Bounds, re-checked on every write”validate_params is called from both initialize and every update_config,
so one helper owns the rules:
| Field | Rule |
|---|---|
fee_bps | ≤ MAX_FEE_BPS |
max_legs | Within the configured range |
min_stake | ≤ max_stake |
max_payout | > 0 |
| probability band | HARD_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_secs | Within [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.
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, 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).
Admin controls
Section titled “Admin controls”| Instruction | Effect |
|---|---|
initialize | Once, at deploy. Pins the AMM program id; sets treasury, fee, bounds. |
update_config | Mutate fee, max legs, stake bounds, max payout, paused, probability band, market-quality floors, claim deadline — and propose an admin rotation |
accept_admin | Signed by the incoming admin, after the timelock |
cancel_admin_rotation | Call off a pending rotation |
update_treasury | Rotate the fee destination (takes the account, not a pubkey) |
seed_vault | Top up the vault from the admin’s USDC |
propose_withdraw_vault | Queue a withdrawal; starts the timelock |
cancel_withdraw_vault | Abort a queued withdrawal |
withdraw_vault | Execute the queued withdrawal after the timelock |
void_parlay | Refund 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_vaultA 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.
What admins cannot do
Section titled “What admins cannot do”- Rotate
amm_program_id. It is a compile-time constant checked atinitialize, and absent fromupdate_config. If an admin could change it, they could point the parlay at a malicious program whoseMarketaccounts 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 toparlay.userby both an explicit equality check and atoken::authority = userconstraint. - 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_createis snapshotted. - Drain reserved funds.
withdraw_vaultcaps at the free balance. - Set a wrong-mint treasury.
update_treasurytakes the account, so thetoken::mintconstraint rejects it at admin time rather than bricking the nextclaim_parlay. - Settle or claim for a user.
settle_legis permissionless but outcome-determined;claim_parlayrequires 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.
Pausing
Section titled “Pausing”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.
Operating the vault
Section titled “Operating the vault”# Current statecurl -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 to watch
Section titled “Headroom to watch”headroom = 0.8 × vault.amount − total_exposureWhen 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.