Skip to content

Leg Eligibility

Not every market can be a leg. The rules exist for two reasons: keeping the price honest, and keeping the vault solvent.

RuleSourceEnforced
Leg countconfig.max_legs, hard range [2, 10]on-chain + client
Market statusOpenon-chain + client
Not past end_time— (MarketExpired, 6021)on-chain + client
Market ageconfig.min_market_age_secson-chain + client
Probability bandconfig.prob_floor_bps … config.prob_ceiling_bpson-chain + client
Market depthconfig.min_market_bon-chain
Market volumeconfig.min_market_volumeon-chain
TWAP freshnesswindow and staleness guardson-chain
No duplicate market—on-chain + client
No same-event legsMarketEventLinkon-chain + client
Collateral mintmust match the vault’son-chain

Nearly all of this is admin-tunable config, not constants baked into the program. The risk posture can be tightened without a program upgrade, but it also means you cannot hardcode the thresholds in a client — read them from the config account.

Client-side checks in parlayEligibility.ts are a convenience mirror, used to grey out markets in the UI before a user wastes a transaction. Every one is re-checked on-chain. Never trust the client copy alone.

Both ends are enforced on-chain, and both are configurable:

config.prob_floor_bps ≤ leg probability ≤ config.prob_ceiling_bps

The units are basis points of PROB_SCALE (1e6), so 2000 bps = 20% = 200_000 units. Values outside the band fail with ProbabilityOutOfRange (6014).

The admin-set band must itself sit inside hard constants:

ConstantValueWhy
HARD_PROB_FLOOR_BPS100 = 1%Below this, multipliers explode — a 1% leg is 100×, so two of them is 10,000×
HARD_PROB_CEILING_BPS9900 = 99%Above this, the inverse-outcome leg would fall below the hard floor

and satisfy floor < ceiling, or update_config fails with InvalidProbabilityBand (6018).

The floor stops the accumulator collapsing. Multiplying enough low-probability legs truncates combined toward zero, and dividing by a near-zero value produces an absurd multiplier. The floor plus the CombinedProbabilityZero guard (6041) is what practically caps leg count at longshot prices.

The ceiling stops legs that contribute nothing. A 98% leg multiplies the payout by 1.02× while adding a real 2% chance of killing the whole slip — strictly bad for the user and pure tail risk for the vault. Note the symmetry the constant’s comment points out: selecting NO at 95% is the same payout-inflation attack as selecting YES at 5%, which is why the band is two-sided rather than a floor alone.

Three market-quality floors, all on-chain config:

FieldGuardRejects
min_market_age_secsMarketTooNew (6015)Markets younger than the configured age
min_market_bMarketTooShallow (6016)Thin AMM liquidity — the cheapest books to push around
min_market_volumeMarketTooIlliquid (6017)Markets with too little settled volume

The age floor is validated against the TWAP window

Section titled “The age floor is validated against the TWAP window”

validate_params requires:

min_market_age_secs ≥ TWAP_WINDOW_SECS // one hour in production

or update_config fails with InvalidMarketAge (6019). The program refuses to let an admin set the age floor below the averaging window even by mistake — because a market younger than one full window has no window to average over, and the TWAP would be computed from a short, thin, easily-shoved sample.

That is the attack this blocks: create a market, push the price to an extreme with one small trade, open a parlay at that price, push it back. The window makes it expensive — you would have to hold the distortion for a meaningful fraction of an hour, against anyone willing to trade the other side.

There are also explicit TWAP guards for the case where the history exists but isn’t usable: TwapWindowTooShort (6048) and TwapTooStale (6049).

Two legs referencing the same market_id are rejected (DuplicateMarket, 6004). Betting YES and NO on the same market would guarantee one leg loses, making the slip a guaranteed loser; betting the same side twice would square the probability of a single event, which is not what the multiplier means.

The subtle one, and the most important.

MarketEventLink seeds = ["market_event_link", market_id: u64 LE]

create_parlay reads that link for every leg and rejects the slip if two legs share an event_id (SameEventCorrelation, 6005).

Why: the multiplier ∏ pᵢ assumes the legs are independent. Consider

  • Team A wins — 60%
  • Team A wins by 2+ — 35%

Multiplied: 21%, so a 4.8× payout. But those aren’t independent at all — the second nearly implies the first. The real joint probability is about 35%, worth 2.9×. The vault would be paying 4.8× on a 2.9× risk, every time, and a maker could farm that difference until the vault was empty.

Event grouping is a blunt instrument — it blocks genuinely independent markets that happen to share an event — but it is cheap to check on-chain and it closes the whole class of correlated-leg attacks. Correlation across events (two matches with a shared dependency) is not caught by the program, which is why operators keep a correlated-market policy alongside it.

The link PDA is derived for every leg. For a market with no event the address resolves to an empty account, which the program reads as “no event.” Mismatched addresses are rejected (InvalidEventLink, 6006).

create_parlay requires exactly 2 × legs.len() remaining accounts:

RangeAccounts
[0..N]Market accounts, in leg order
[N..2N]MarketEventLink PDAs, derived per leg’s market_id

A length mismatch is RemainingAccountsLengthMismatch (6007).

The parlay program reads AMM state without CPI — it deserialises the accounts directly. That means it has to defend itself:

1. require!(account.owner == amm_program_id, InvalidMarketOwner); // 6036
2. let market = AmmMarket::try_deserialize(&mut data.as_ref())
.map_err(|_| InvalidMarketAccount)?; // 6037

Step 1 rejects accounts owned by anything else. Step 2 invokes Anchor’s typed deserialiser, which asserts the 8-byte discriminator matches sha256("account:Market")[..8] — rejecting every other AMM-owned account type, Config and MarketEventLink included — and then decodes with the AMM crate’s own Borsh implementation.

Layout drift is caught at build time: the parlay links the AMM crate as a workspace path dependency, so an incompatible AMM change fails compilation rather than silently misreading bytes at runtime.

amm_program_id is a compile-time constant (EXPECTED_AMM_PROGRAM_ID) checked at initialize, and deliberately absent from update_config. Pinning it in the binary rather than in caller-supplied state means even a misconfigured init cannot wire in a fake AMM whose Market accounts decode to attacker-chosen outcomes. Rotating requires deploying a new parlay program version.

One consequence: the parlay reads AMM markets only. CLOB markets are not currently usable as legs. → CLOB Markets as Legs

src/utils/parlayEligibility.ts

Checks status, end time, market age and probability band, and greys out ineligible markets in the browse grid. Read the thresholds from the config account rather than hardcoding them — every one is admin-tunable. Demo and template legs carry a synthetic negative marketId and no marketAddress, and are blocked from submission (parlay.errors.demoLeg) — they’re previews, not placeable bets.

ErrorCodeTrigger
TooFewLegs6002legs.len() < 2
TooManyLegs6003legs.len() > max_legs
DuplicateMarket6004Same market_id twice
MarketNotOpen6012A leg’s market isn’t Open
ProbabilityOutOfRange6014Outside the configured [floor, ceiling] band
InvalidMarketOwner6036Account not owned by the AMM program
InvalidMarketAccount6037Discriminator or layout check failed
SameEventCorrelation6005Two legs share an event_id
InvalidEventLink6006Link PDA mismatch or wrong embedded market_id
RemainingAccountsLengthMismatch6007Not exactly 2 × legs.len()
StakeTooLow / StakeTooHigh6008 / 6009Stake outside [min_stake, max_stake]
MarketTooNew6015Younger than min_market_age_secs
MarketTooShallow6016b below min_market_b
MarketTooIlliquid6017Volume below min_market_volume
MarketExpired6021A leg’s market is past its end_time
CollateralMintMismatch6023A leg settles in a different mint than the vault
TwapWindowTooShort / TwapTooStale6048 / 6049Not enough usable price history

→ Error Codes