Leg Eligibility
Not every market can be a leg. The rules exist for two reasons: keeping the price honest, and keeping the vault solvent.
The full set
Section titled “The full set”| Rule | Source | Enforced |
|---|---|---|
| Leg count | config.max_legs, hard range [2, 10] | on-chain + client |
| Market status | Open | on-chain + client |
Not past end_time | — (MarketExpired, 6021) | on-chain + client |
| Market age | config.min_market_age_secs | on-chain + client |
| Probability band | config.prob_floor_bps … config.prob_ceiling_bps | on-chain + client |
| Market depth | config.min_market_b | on-chain |
| Market volume | config.min_market_volume | on-chain |
| TWAP freshness | window and staleness guards | on-chain |
| No duplicate market | — | on-chain + client |
| No same-event legs | MarketEventLink | on-chain + client |
| Collateral mint | must match the vault’s | on-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.
The probability band
Section titled “The probability band”Both ends are enforced on-chain, and both are configurable:
config.prob_floor_bps ≤ leg probability ≤ config.prob_ceiling_bpsThe 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:
| Constant | Value | Why |
|---|---|---|
HARD_PROB_FLOOR_BPS | 100 = 1% | Below this, multipliers explode — a 1% leg is 100×, so two of them is 10,000× |
HARD_PROB_CEILING_BPS | 9900 = 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.
Market age, depth and volume
Section titled “Market age, depth and volume”Three market-quality floors, all on-chain config:
| Field | Guard | Rejects |
|---|---|---|
min_market_age_secs | MarketTooNew (6015) | Markets younger than the configured age |
min_market_b | MarketTooShallow (6016) | Thin AMM liquidity — the cheapest books to push around |
min_market_volume | MarketTooIlliquid (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 productionor 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).
Duplicate markets
Section titled “Duplicate markets”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.
Same-event correlation
Section titled “Same-event correlation”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.
Markets with no event
Section titled “Markets with no event”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).
Remaining accounts
Section titled “Remaining accounts”create_parlay requires exactly 2 × legs.len() remaining accounts:
| Range | Accounts |
|---|---|
[0..N] | Market accounts, in leg order |
[N..2N] | MarketEventLink PDAs, derived per leg’s market_id |
A length mismatch is RemainingAccountsLengthMismatch (6007).
How accounts are validated
Section titled “How accounts are validated”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); // 60362. let market = AmmMarket::try_deserialize(&mut data.as_ref()) .map_err(|_| InvalidMarketAccount)?; // 6037Step 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
Client-side mirror
Section titled “Client-side mirror”src/utils/parlayEligibility.tsChecks 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.
Error reference
Section titled “Error reference”| Error | Code | Trigger |
|---|---|---|
TooFewLegs | 6002 | legs.len() < 2 |
TooManyLegs | 6003 | legs.len() > max_legs |
DuplicateMarket | 6004 | Same market_id twice |
MarketNotOpen | 6012 | A leg’s market isn’t Open |
ProbabilityOutOfRange | 6014 | Outside the configured [floor, ceiling] band |
InvalidMarketOwner | 6036 | Account not owned by the AMM program |
InvalidMarketAccount | 6037 | Discriminator or layout check failed |
SameEventCorrelation | 6005 | Two legs share an event_id |
InvalidEventLink | 6006 | Link PDA mismatch or wrong embedded market_id |
RemainingAccountsLengthMismatch | 6007 | Not exactly 2 × legs.len() |
StakeTooLow / StakeTooHigh | 6008 / 6009 | Stake outside [min_stake, max_stake] |
MarketTooNew | 6015 | Younger than min_market_age_secs |
MarketTooShallow | 6016 | b below min_market_b |
MarketTooIlliquid | 6017 | Volume below min_market_volume |
MarketExpired | 6021 | A leg’s market is past its end_time |
CollateralMintMismatch | 6023 | A leg settles in a different mint than the vault |
TwapWindowTooShort / TwapTooStale | 6048 / 6049 | Not enough usable price history |