CLOB Markets as Legs
The parlay program prices a leg from the underlying market’s state. For AMM
markets that means the LMSR curve over (q_yes, q_no, b) — a pure function of
state that can’t be read stale.
CLOB markets have no curve. Their price lives in an off-chain order book the
parlay program cannot see and would not trust if it could. So the markets
program keeps its own price accumulator, advanced only by real
settlements, designed to be read as a one-hour TWAP.
The accumulator
Section titled “The accumulator”Five fields on the Market PDA:
| Field | Type | Meaning |
|---|---|---|
last_price_yes | u64 | Last executed YES price, 1e6 fixed point. Starts at 500_000 (50/50). |
price_cumulative_yes | u128 | Running integral of last_price_yes × dt, in µprice·seconds |
last_twap_ts | i64 | When the integral was last advanced |
twap_anchor_{cur,prev}_{cum,ts} | — | Rolling anchors of the integral |
total_volume | u64 | Cumulative collateral notional of settled fills |
Maintained only by execute_trade. Nothing else writes them — not mint,
not merge, not an admin instruction.
What a fill does
Section titled “What a fill does”Per fill, in order:
- Accrue the integral at the price that was live before this fill —
price_cumulative_yes += last_price_yes × (now − last_twap_ts). - Set
last_price_yesfrom the executed price: collateral ÷ tokens, which ismaking/takingfor a BUY maker andtaking/makingfor a SELL maker. NO legs are stored as the YES complement, so one field describes the whole market. - Add the fill’s collateral notional to
total_volume—makingfor a BUY,takingfor a SELL.
Accruing before updating is what makes the integral honest: the time the old price was live is credited at the old price.
Reading a TWAP from one anchor
Section titled “Reading a TWAP from one anchor”The naive way to store a TWAP is a ring buffer of samples, which costs account space and compute. Instead the accumulator keeps two anchors of the integral:
cur — refreshed once it is a full TWAP_WINDOW_SECS (1 hour) oldprev — the previous cur, retired when cur rollsA reader computes:
twap = (price_cumulative_yes − twap_anchor_prev_cum) / (now − twap_anchor_prev_ts)Because prev is always at least one full window old, that ratio is the
one-hour time-weighted average — from a single subtraction and a single
division, with no iteration and no stored history.
The logic is a direct port of the audited AMM accrue_twap: same window,
same anchor roll, same 50/50 initialisation.
Why a TWAP and not spot
Section titled “Why a TWAP and not spot”The parlay locks a leg’s probability at create time and pays at that multiplier forever. If it read spot, the attack is cheap:
1. Push the book to an extreme with one small trade in a thin market2. Open a large parlay at the distorted probability3. Push it backOne slot of manipulation, a permanent mispriced payout. A time-weighted average makes that expensive — you would have to hold the distortion for a meaningful fraction of an hour, against anyone willing to trade the other side.
It is also why the parlay’s min_market_age_secs is validated to be at
least one full TWAP_WINDOW_SECS: a market younger than the window has no
window to average over.
Volume as a liquidity floor
Section titled “Volume as a liquidity floor”total_volume counts settled fills only. Mint and merge conversions are
explicitly excluded — they create and destroy tokens at par, with no price
discovery. Counting them would let anyone inflate a market’s apparent depth
by minting and merging against themselves at zero cost.
The parlay’s min_market_volume guard reads the equivalent AMM field
(MarketTooIlliquid). The CLOB accumulator was built to line up with it.
Fee-exempt fills still move the price
Section titled “Fee-exempt fills still move the price”A fill can settle with zero fee — nothing reaches the fee vault — and still advances the accumulator. That is intentional: the price moved, so the price record should reflect it. It is also a manipulation vector worth naming: fee exemption makes shoving the accumulator cheaper, which is precisely why the TWAP window rather than the fee is the thing standing between a manipulator and a mispriced parlay.
Why the parlay can’t read a CLOB market
Section titled “Why the parlay can’t read a CLOB market”Three separate reasons:
1. The AMM program id is compiled in
Section titled “1. The AMM program id is compiled in”pub const EXPECTED_AMM_PROGRAM_ID: Pubkey = anchor_lang::pubkey!("4Pe4r9FpaAtdU2fkyVLgNSKqhULXs6udKvDZf7Wm1yVE");initialize requires the caller’s amm_program_id to equal that constant
(InvalidAmmProgramId, 6020), and update_config cannot change it. It is
pinned at compile time, not merely frozen in state — so a misconfigured
init can’t wire in a fake AMM, and rotating requires a redeploy.
market_reader::read_market then enforces account.owner == amm_program_id.
A CLOB markets-owned account fails there with InvalidMarketOwner (6036).
A second market source needs a second program id, which is a program change.
2. The reader decodes the AMM’s Market type
Section titled “2. The reader decodes the AMM’s Market type”let market = AmmMarket::try_deserialize(&mut data.as_ref()) .map_err(|_| ParlayError::InvalidMarketAccount)?;AmmMarket is high_market_program’s struct, linked as a workspace path
dependency. The CLOB markets crate’s Market is a different type in a
different crate.
Note that the Anchor discriminator would not catch the mismatch — both
are named Market, so sha256("account:Market")[..8] is identical. The
owner check in step 1 is what actually stops it. Worth knowing if you are
reasoning about the safety of these reads.
3. MarketData needs LMSR state the CLOB doesn’t have
Section titled “3. MarketData needs LMSR state the CLOB doesn’t have”The parlay’s internal MarketData carries q_yes, q_no and b, and
twap_price_yes derives its spot from lmsr::price_yes(q_yes, q_no, b)
before blending with the anchors. A CLOB order-book market has none of those
fields — there is no curve and no liquidity parameter.
So the parlay needs a second pricing path that reads last_price_yes and the
anchors directly, without LMSR state. The min_market_b guard
(MarketTooShallow) also has no CLOB equivalent and would need a different
depth proxy — total_volume is the obvious candidate.
Configuration
Section titled “Configuration”TWAP_WINDOW_SECS = 3600 // one hour in productionPROB_SCALE = 1_000_000 // fixed-point scale for last_price_yesThe window is shrinkable for integration tests via a Cargo feature — a full hour of wall-clock per test case is not viable. Production builds use the hour.
Practical implications
Section titled “Practical implications”For traders. GoCombo legs are AMM markets today. A CLOB-only market cannot be added to a slip at all — not because it is too new, but because the program cannot read it.
For market makers. Your CLOB fills already move the accumulator, and it already carries a full price history. Nothing consumes it yet, but it will be the pricing input the day CLOB markets become legs — so the history you are writing now is not wasted.
For integrators. Read the accumulator from the Market account if you
want to reproduce what a CLOB leg would price at. Don’t reproduce it from
the order book: they are different numbers, and the program would use the
former.