Skip to content

Risk Controls

This page describes the risk controls that are live on the house-banked parlay book, and the operational policy around them. It is written for whoever runs the vault.

Every open slip is a short option written by the vault. The payout is fixed at create time, so the vault’s loss on a winning slip is potential_payout − stake, known in advance and reserved in full.

total_exposure = Σ potential_payout over all Active and Won-unclaimed slips

The exposure is reserved at full notional the moment a slip is created — there is no netting, no expected-value discount. A 20× slip reserves 20× the stake even though it is 5% likely to pay.

That conservatism is the point: the vault’s solvency doesn’t depend on any probability estimate being right.

total_exposure + new_potential_payout ≤ 0.8 × vault.amount

Checked on every create_parlay. Failing it rejects the slip with ExposureLimitExceeded (6011).

Consequences to plan around:

  • Headroom is a hard ceiling, not a soft target. There is no queue and no partial fill.
  • Large slips fail before small ones. As headroom shrinks, users experience apparently random rejections — big bets fail while small ones go through.

The vault’s current balance and reserved exposure are public:

Terminal window
curl -s https://prod-api.gomarket.io/api/parlays/vault/

The API is a mirror maintained from webhooks; on-chain state is authoritative. → Parlays API

CapField
Max gross payout per slipconfig.max_payout
Max stakeconfig.max_stake
Min stakeconfig.min_stake
Max legsconfig.max_legs (≤ 10)

max_payout is the tail-risk control: it bounds the single worst outcome regardless of what multiplier the math produces. Combined with the u64::try_from on the payout computation, it is the program’s only protection against a multiplier explosion from a degenerate combined probability.

config.prob_floor_bps ≤ prob ≤ config.prob_ceiling_bps

Both ends are on-chain and configurable, bounded by hard constants at 1% and 99%. Two jobs. It stops the 1e12 accumulator truncating to zero on deep-longshot slips (CombinedProbabilityZero, 6041), and it caps how extreme a multiplier can be produced at all — ten legs at the floor would fail the guard rather than emit a junk number.

The ceiling stops legs that contribute nothing: a 98% leg adds 1.02× to the payout while adding a real 2% chance of killing the slip — bad for the user and pure tail risk for the vault. It is enforced on-chain, not merely in the client.

The on-chain guard is blunt: two legs from the same event are rejected (SameEventCorrelation, 6005), read from the MarketEventLink PDA at create time.

It works because the multiplier ∏ pᵢ assumes independence. “Team A wins” (60%) and “Team A wins by 2+” (35%) multiply to 21% — a 4.8× payout — when the real joint probability is around 35%, worth 2.9×. Left open, a maker farms that gap until the vault is empty.

The check is per event, so it catches the correlation that comes from two legs describing the same contest. Correlation that spans events — two matches with a shared dependency, two markets on the same underlying driver — is separate event_ids and passes.

That boundary is deliberate: an on-chain check has to be cheap and deterministic, and event_id equality is both. Correlation beyond it is handled by how events are curated rather than by the program, which is why event grouping is a risk decision and not just a navigation one.

is_paused = true → create_parlay rejected with Paused (6000)

Scope is deliberately narrow: pausing stops new risk only. Settling and claiming continue to work, and existing slips resolve and pay out normally.

Pausing is not a freeze on user funds, and shouldn’t be operated as one. Use it when the pricing inputs are untrustworthy — a market data outage, a suspected manipulation, a correlation discovered after the fact — and let the existing book run off.

Legs price off a one-hour TWAP, not spot. → CLOB Markets as Legs

The attack it blocks: push a thin market to an extreme, open a large parlay at the distorted probability, push it back. Against a time-weighted average that requires holding the distortion for a meaningful fraction of an hour, against anyone willing to trade the other side.

Supporting guards: min_market_age_secs (validated to be ≥ the TWAP window, so an admin cannot set it below the averaging period even by mistake), min_market_b, min_market_volume, and the explicit TwapWindowTooShort / TwapTooStale checks.

AccountRole
VaultHolds stakes, pays winners. Authority is the PDA itself.
TreasuryReceives fees at claim. config.treasury.
AdminSigns config changes, vault seed/withdraw, voids.

In production the admin is a Squads multisig. The backend never signs parlay transactions — admin actions go through the multisig.

Why
Rotate amm_program_idAbsent from update_config. A rotated program id would let an admin point the parlay at a malicious AMM whose Market accounts decode to chosen outcomes. Requires a new program deployment.
Redirect a refundvoid_parlay’s destination is bound to parlay.user by an equality check and a token::authority = user constraint.
Change a placed slip’s feefee_bps_at_create is snapshotted.
Drain reserved fundswithdraw_vault caps at vault.amount − total_exposure.
Settle or claim for a usersettle_leg is permissionless but outcome-determined; claim_parlay needs the user’s signature.

update_config applies to future parlays only. Every placed slip carries its own snapshot of what matters.

Everything the solvency rules depend on is public:

Terminal window
curl -s https://prod-api.gomarket.io/api/parlays/vault/
FieldMeaning
balanceUSDC in the vault
total_exposureSum of potential_payout across open, unclaimed slips
max_payout_cap, min_stake, max_stake, max_legsThe configured caps

Headroom for new slips is 0.8 × balance − total_exposure. When it nears zero, large slips start failing with ExposureLimitExceeded while small ones still succeed — that’s the 80% rule working, not an error.

The API is a mirror maintained from webhooks; the on-chain accounts are authoritative. → Parlays API · Contracts & Audits