Skip to content

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.

Five fields on the Market PDA:

FieldTypeMeaning
last_price_yesu64Last executed YES price, 1e6 fixed point. Starts at 500_000 (50/50).
price_cumulative_yesu128Running integral of last_price_yes × dt, in µprice·seconds
last_twap_tsi64When the integral was last advanced
twap_anchor_{cur,prev}_{cum,ts}—Rolling anchors of the integral
total_volumeu64Cumulative collateral notional of settled fills

Maintained only by execute_trade. Nothing else writes them — not mint, not merge, not an admin instruction.

Per fill, in order:

  1. Accrue the integral at the price that was live before this fill — price_cumulative_yes += last_price_yes × (now − last_twap_ts).
  2. Set last_price_yes from the executed price: collateral ÷ tokens, which is making/taking for a BUY maker and taking/making for a SELL maker. NO legs are stored as the YES complement, so one field describes the whole market.
  3. Add the fill’s collateral notional to total_volume — making for a BUY, taking for a SELL.

Accruing before updating is what makes the integral honest: the time the old price was live is credited at the old price.

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) old
prev — the previous cur, retired when cur rolls

A 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.

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 market
2. Open a large parlay at the distorted probability
3. Push it back

One 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.

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.

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.

Three separate reasons:

high-market-parlay/src/constants.rs
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.

TWAP_WINDOW_SECS = 3600 // one hour in production
PROB_SCALE = 1_000_000 // fixed-point scale for last_price_yes

The 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.

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.