Strikes and Ticks
Every strike in DeepBook Predict is an absolute integer tick counted from zero. One expiry market fixes one tick_size, and the raw strike is the product:
raw_strike = tick * tick_size
A position is a pair of those ticks, (lower_tick, higher_tick), and it pays its full quantity when settlement lands inside the half-open region (lower, higher]. There is no second strike representation anywhere in the protocol: the same tick means the same price at the mint call, in the events, in the payout index, and at settlement. The protocol expresses nothing relative to a per-market origin, and market creation reads no live spot price.
Positions are not objects. A mint returns a packed u256 order ID, and that ID together with its market identifies the position.
The tick grid
Each expiry market snapshots its tick_size from the cadence configuration the registry holds for its underlying, and never changes it. Raw strikes use the protocol's 1e9 fixed-point price scale, so a tick_size of 10_000_000 is 0.01 USD per tick. Every configured tick_size must be a multiple of constants::market_tick_size_unit!(), which is 10_000.
Read the grid off an ExpiryMarket with these public accessors:
public fun tick_size(market: &ExpiryMarket): u64
public fun admission_tick_size(market: &ExpiryMarket): u64
public fun reference_tick(market: &ExpiryMarket): Option<u64>
public fun reference_tick_source_timestamp_ms(market: &ExpiryMarket): u64
Source for the tick accessors
packages/predict/sources/expiry_market.move. You probably need to run `pnpm prebuild` and restart the site.The only conversion in the other direction is range_codec::strike_from_tick, which applies the sentinel mapping first and multiplies otherwise. It is the single public entry into the raw-price domain:
packages/predict/sources/strike_exposure/range_codec.move. You probably need to run `pnpm prebuild` and restart the site.Both the Mainnet and the Testnet deployment run one underlying, BTC, with 2 enabled cadences, and both cadences use the same grids:
| Cadence | tick_size raw | Fine grid | admission_tick_size raw | Admission grid |
|---|---|---|---|---|
| 1m | 10_000_000 | 0.01 USD | 1_000_000_000 | 1 USD |
| 5m | 10_000_000 | 0.01 USD | 1_000_000_000 | 1 USD |
The registry also holds the 1h, 1d, 1w, and 1mo cadences in a disabled state, and a disabled cadence reads back as an all-zero configuration. Verification on 2026-09-10 confirmed those values on both networks. Read the live values with registry::cadence_config or registry::cadence_configs, or as tickSize and admissionTickSize from read.markets(), rather than hardcoding them, because an admin can change a cadence and each market keeps the terms it received at creation.
A position is a tick pair
Both mint functions take lower_tick and higher_tick as separate u64 arguments, and both order events carry them the same way. Only the durable order ID packs the pair into one integer.
The payout region is left-open and right-closed. A settlement exactly at the lower strike loses, and a settlement exactly at the higher strike wins. The chain decides that in range_codec::settlement_in_range, which first rounds the settlement price up onto the tick grid:
prefix_limit_tick = ceil(settlement / tick_size)
in_range = lower_tick < prefix_limit_tick
&& (higher_tick == pos_inf_tick || prefix_limit_tick <= higher_tick)
Rounding up is what makes the boundary asymmetric: a finite boundary tick is active in the settlement walk only when its own strike sits strictly below the settlement price. The higher_tick == pos_inf_tick branch short-circuits because a settlement above the encodable range produces a prefix_limit_tick that legitimately exceeds pos_inf_tick, so the comparison is a plain u64 bound and the chain never validates it as a domain tick.
Worked against a 0.01 USD grid and a settlement of exactly 105,000 USD, prefix_limit_tick is 10_500_000:
| Position | Ticks | Test | Result |
|---|---|---|---|
| 104,000 to 105,000 USD | (10_400_000, 10_500_000] | 10_400_000 < 10_500_000 and 10_500_000 <= 10_500_000 | Pays in full |
| 105,000 to 106,000 USD | (10_500_000, 10_600_000] | 10_500_000 < 10_500_000 is false | Pays zero |
A winning position pays its full quantity, and the contract measures quantity in base units of the quote coin, USDC, where 1_000_000 pays 1 USD at settlement. On Mainnet that coin is native USDC; on Testnet it is a mintable test coin at the same usdc::USDC module path that displays as DUSDC. Take the exact type from getConfig(network).quoteCoinType.
Sentinel ticks
The protocol reserves 2 ticks so that an open-ended range needs no artificial outer strike:
| End | Tick | Raw strike | Constant | Visibility |
|---|---|---|---|---|
| Negative infinity | 0 | 0 | constants::neg_inf!() | public |
| Positive infinity | 1073741823 | std::u64::max_value!() | constants::pos_inf!() | public |
1073741823 is (1 << 30) - 1, the maximum value of the 30-bit tick field. Onchain it is constants::pos_inf_tick!(), which is public(package) and therefore not callable from an integrator package. Hardcode the literal 1073741823, or take it from the SDK's POS_INF_TICK export. The raw-strike sentinels neg_inf!() and pos_inf!() are public, but they live on the price axis rather than the tick axis, so they are not what a mint call takes.
Finite ticks occupy 1 through 1073741822. A tick above pos_inf_tick aborts with order::EInvalidTick.
Choose a range shape
A directional view is a one-sided range, and the protocol prices it through exactly the same path as a two-sided one. Pick the shape that matches your view on settlement:
| Your view on settlement | Shape | Ticks | Wins when |
|---|---|---|---|
| Ends above one level | Directional up | (K, 1073741823] | settlement > K |
| Ends at or below one level | Directional down | (0, K] | settlement <= K |
| Lands between 2 levels | Two-sided range | (L, H] | L < settlement <= H |
A one-sided range wins on an unbounded outcome, so it costs more per contract and wins more often. A two-sided range wins on a bounded outcome, so it costs less per contract and wins less often. Ranges suit a view on where a price settles rather than on which direction it moves.
The order module rejects 2 shapes outright:
lower_tick >= higher_tickaborts withEInvalidRange, so a zero-width or inverted band cannot exist.- The fully open range
(0, 1073741823]aborts withEInvalidRange, because it always pays and is not a tradable contract.
The tick grid and the admission grid
Confusing the 2 grids is the most common integration failure on this surface. A new mint's strike must satisfy both conditions:
- Fine grid: The raw strike must be an exact multiple of the market's
tick_size, otherwise it is not a tick at all. - Admission grid: Each finite boundary must additionally land on the coarser
admission_tick_size, which the cadence configuration sets per cadence.
The chain tests the second condition in tick units. With admission_multiple = admission_tick_size / tick_size, the chain admits a boundary when it is a sentinel, when tick % admission_multiple == 0, or when it equals the market's recorded reference_tick. Anything else aborts with strike_exposure::EInvalidAdmissionTick.
On both enabled cadences, admission_multiple is 100: a 0.01 USD fine grid under a 1 USD admission grid.
The reference_tick is the one finite strike a market admits off the admission grid. It is the market's window anchor, floored onto the fine grid from an exact Propbook Pyth observation at reference_tick_source_timestamp_ms, and a permissionless call records it:
packages/predict/sources/expiry_market.move. You probably need to run `pnpm prebuild` and restart the site.reference_tick reads back as none until that call succeeds, and recording a different tick afterwards aborts with strike_exposure::EReferenceTickAlreadySet. Calling it before the source observation lands in the feed aborts with expiry_market::EReferenceTickObservationMissing.
Grid state lives in the market's StrikeExposure, which has no public functions of its own. An ExpiryMarket getter mirrors every value an integrator needs:
Source for the StrikeExposure struct
packages/predict/sources/strike_exposure/strike_exposure.move. You probably need to run `pnpm prebuild` and restart the site.Build a valid range
Follow these steps before you submit a mint:
- Resolve the market for your underlying and expiry, then read
tick_size,admission_tick_size, andreference_tickfrom thatExpiryMarket. - Convert each finite boundary price to a tick with
tick = raw_strike / tick_size, and reject any price that leaves a remainder. - Snap each finite tick to the admission grid, or set it to the market's
reference_tick. - Replace an open lower bound with
0and an open higher bound with1073741823. - Confirm
lower_tick < higher_tickand that the pair is not(0, 1073741823]. - Round the quantity to a whole lot of
10_000base units, which is 0.01 USD of payout, then check the premium against the minimum below.
The lot is the granularity, not a tradable minimum. Every mint must also produce a premium of at least 1_000_000 base units, which is 1 USDC, or it aborts with strike_exposure_config::EPremiumBelowMinimum. Premium is quantity * entry_probability and the contract caps entry probability at 0.99, so the smallest admissible quantity never falls below about 1.02 USDC of payout, and it rises steeply as the strike moves away from the money. A far out-of-the-money strike fails earlier still, on the entry-probability band. See Mint admission errors for both codes and the measured floors.
A valid range can still fail on timing. Live mints and live redeems abort with protocol_config::ETradeWindowClosed inside the last no_trade_window_ms before expiry, deployed at 2,000 ms on both networks, so check the market's remaining time before you submit; see Predict for the window.
Using a 0.01 USD fine grid under a 1 USD admission grid, the same numeric strike passes or fails on where it lands:
| Strike | Tick | Result |
|---|---|---|
| 105,000 USD | 10_500_000 | Admitted |
| 105,000.50 USD | 10_500_050 | Aborts with EInvalidAdmissionTick, unless it equals the market's reference_tick |
| 105,000.005 USD | Not a tick | Off the fine grid |
The packed order ID
mint_exact_quantity and mint_exact_amount both return a u256 order ID. That integer carries the durable contract terms and nothing else, so the ID deliberately omits mint-only inputs such as entry probability, premium, and fee policy. Tightening admission policy in a later version therefore cannot retroactively invalidate an existing ID.
The layout is dense in the low bits:
| Field | Offset | Width | Meaning |
|---|---|---|---|
quantity_lots | Bit 100 | 32 bits | Quantity in lots. Payout quantity is quantity_lots * 10_000 base units. |
lower_tick | Bit 70 | 30 bits | Lower boundary tick, where 0 is negative infinity. |
higher_tick | Bit 40 | 30 bits | Higher boundary tick, where 1073741823 is positive infinity. |
sequence | Bits 0 to 39 | 40 bits | Expiry-local monotonic counter. |
Total width is 132 bits, and any bit set at or above 132 aborts with order::EInvalidOrderId. Quantity must be nonzero, a whole multiple of 10_000, and at most 4_294_967_295 lots. Those are encoding bounds only. Mint admission holds a higher effective floor, because the premium has to clear 1 USDC; see Build a valid range.
Treat the ID as an opaque handle. Pass it back unchanged to redeem, claim, or query a position, and read range facts from the order events rather than by unpacking bits yourself.
The sequence counter is expiry-local, so an order ID is unique only within one expiry market. An ID alone carries no expiry or market identity. A position is the pair (expiry_market_id, order_id), which is why every trade event carries expiry_market_id alongside order_id.
Source for the Order view
order declares no public functions. The module validates and decodes packed IDs for the rest of the package, and integrators observe the same terms through the OrderMinted, LiveOrderRedeemed, and SettledOrderRedeemed events.
packages/predict/sources/order.move. You probably need to run `pnpm prebuild` and restart the site.Where Predict tracks positions
A holder's positions live in Predict's app-data slot on the account package's Account, not in the expiry market. predict_account::PredictData keeps a Table<PositionKey, Position> where PositionKey is exactly the (expiry_market_id, order_id) pair, and the stored Position records the root order ID plus the millisecond timestamp at which the position opened.
The root order ID is the original mint's ID, carried forward unchanged across partial-close replacements. One economic position keeps one stable handle even though a partial close retires the current ID and issues a new one. Check membership with the public read:
public fun has_position(account: &Account, expiry_market_id: ID, order_id: u256): bool
For the account model, the shared AccountWrapper, and the Auth hot potato that opens it, see Accounts and Custody.
Find a market
Resolve an ExpiryMarket object ID from the shared Registry:
public fun expiry_market_id(
registry: &Registry,
propbook_underlying_id: u32,
expiry: u64,
): Option<ID>
Source for expiry_market_id
packages/predict/sources/registry/registry.move. You probably need to run `pnpm prebuild` and restart the site.The registry stores those IDs in a Table keyed by market_manager::MarketKey, a u32 underlying ID paired with a u64 expiry. That struct is an internal uniqueness key: it has no accessors, no exported constructor, and no role in any integrator call. Use expiry_market_id instead, and take propbook_underlying_id from getConfig(network).underlyings or the deployment manifest, where BTC is 1 on both networks.
Source for the MarketKey uniqueness key
packages/predict/sources/registry/market_manager.move. You probably need to run `pnpm prebuild` and restart the site.Ticks in the TypeScript SDK
@mysten/deepbook-v3 version 2.3.0 or later ships a /predict subpath that does the tick arithmetic for you on either network. Describe a position with a MarketDescriptor in USD, and the SDK resolves the market, converts each strike, and admission-checks each finite boundary before it builds the transaction.
The descriptor has 2 arms:
- Binary arm:
{ side: 'up' | 'down', strike: number | 'reference' }, where'reference'resolves to the market'sreferencePrice. - Range arm:
{ side: 'range', lower: number, upper: number }, where both bounds are finite USD prices andlowermust be belowupper.
Each arm maps onto the same tick pair the chain takes:
| Descriptor | lowerTick | higherTick |
|---|---|---|
{ side: 'up', strike: K } | K / tickSize | POS_INF_TICK |
{ side: 'down', strike: K } | 0 | K / tickSize |
{ side: 'range', lower: L, upper: H } | L / tickSize | H / tickSize |
The SDK exports POS_INF_TICK as a bigint equal to 1073741823n. strike: 'reference' is available on the binary arm only, and it throws while the market has no reference tick recorded. Recording one is permissionless, so a caller blocked this way can unblock themselves by calling set_reference_tick rather than waiting for someone else.
read.markets() returns tickSize, admissionTickSize, and referencePrice as USD decimals, so snap a target price with plain arithmetic:
const markets = await client.predict.read.markets();
const m = markets[0];
const strike = Math.round(target / m.admissionTickSize) * m.admissionTickSize;
The SDK raises a typed PredictInputError before it builds a transaction when a strike is off the fine grid, off the admission grid and not the reference price, outside the finite tick domain, or when lower is not below upper.
The following example mints a directional position, quoting first and capping the all-in cost; NETWORK in the examples package selects Testnet or Mainnet:
import type { MarketDescriptor, MintQuote } from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { UNDERLYING } from './config.js';
import { admissibleStrike, tradeableMarket } from './markets.js';
// Quote, then mint with a cap derived from the quote.
//
// Both caps are optional, and omitting them is not a safe default: the SDK sends
// U64_MAX for a missing `maxCost` or `maxProbability`, which leaves the mint
// genuinely uncapped against any price move between the quote and execution.
// This builder mints an exact payout quantity, so the premium alone cannot exceed
// `quantity`; fees are charged on top, and `maxCost` is what bounds the total
// debit. `tx.mintAmount` is the one that can reach the whole balance, because
// there fees are charged on top of `spend` and `maxCost` is the only bound on the
// full withdrawal. Always pass at least `maxCost`.
export async function mintDirectional(params: {
owner: string;
side: 'up' | 'down';
// Maximum payout in USD, at $1 per contract. Must be a whole $0.01 lot.
quantity: number;
// Omit to trade at the market's on-chain reference price, the window anchor.
targetStrikeUsd?: number;
}): Promise<{ tx: Transaction; quote: MintQuote; descriptor: MarketDescriptor }> {
const { owner, side, quantity, targetStrikeUsd } = params;
const market = await tradeableMarket();
const descriptor: MarketDescriptor = {
underlying: UNDERLYING,
expiryMs: market.expiryMs,
// Pin the exact market object that was read, rather than whatever the
// registry resolves to at submit time.
marketId: market.id,
side,
strike:
targetStrikeUsd === undefined
? 'reference'
: admissibleStrike(market, targetStrikeUsd),
};
// The quote dry-runs the identical transaction the mint builds, against the
// real account and the real fee path, so it doubles as preflight: it throws
// the same typed errors the mint would.
const quote = await client.predict.read.quoteMint(owner, descriptor, { quantity });
// `quote.cost` is the all-in account debit, not the premium. Raw amounts are
// integers at six decimals, so round the cap to six decimals: a finer value
// throws `PredictInputError`.
const maxCost = Math.ceil(quote.cost * 1.01 * 1e6) / 1e6;
// A second, independent ceiling on the fill price, 0..1 per $1 of payout.
const maxProbability = Math.min(1, Number((quote.entryProbability * 1.02).toFixed(6)));
const tx = await client.predict.tx.mint(owner, descriptor, {
quantity,
maxCost,
maxProbability,
});
// The transaction is ready to sign. Nothing here signs it, and the SDK never
// holds keys.
return { tx, quote, descriptor };
}
This one mints a two-sided range through the same builder, with both bounds snapped to the admission grid:
import type { MarketDescriptor, MintQuote } from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { UNDERLYING } from './config.js';
import { admissibleStrike, tradeableMarket } from './markets.js';
// A range position pays out when settlement lands inside `(lower, upper]`, which
// is left-open and right-closed. Both bounds are finite numeric strikes, so both
// must sit on the market's admission grid. There is no `strike` field on the
// range arm of the descriptor, so `'reference'` has no meaning here: center the
// band on the market's reference price instead.
export async function mintRange(params: {
owner: string;
// Maximum payout in USD, at $1 per contract. Must be a whole $0.01 lot.
quantity: number;
// Half-width of the band around the window anchor, in USD.
halfWidthUsd: number;
}): Promise<{ tx: Transaction; quote: MintQuote; lower: number; upper: number }> {
const { owner, quantity, halfWidthUsd } = params;
const market = await tradeableMarket();
const anchor = market.referencePrice;
if (anchor === null) {
throw new Error('The market has no reference price yet. Retry once the keeper seeds it.');
}
const lower = admissibleStrike(market, anchor - halfWidthUsd);
// Each bound rounds independently, so a band narrower than one admission tick
// can collapse onto a single tick. The chain requires `lower` strictly below
// `upper`.
const upper = Math.max(
admissibleStrike(market, anchor + halfWidthUsd),
lower + market.admissionTickSize,
);
const descriptor: MarketDescriptor = {
underlying: UNDERLYING,
expiryMs: market.expiryMs,
marketId: market.id,
side: 'range',
lower,
upper,
};
// Range positions quote and mint through the same builders as binary ones.
const quote = await client.predict.read.quoteMint(owner, descriptor, { quantity });
// Cap both dimensions. Omitting either sends U64_MAX for it, and a cost cap
// alone still lets the fill price move: pass `maxProbability` as well.
const maxCost = Math.ceil(quote.cost * 1.01 * 1e6) / 1e6;
const maxProbability = Math.min(1, Number((quote.entryProbability * 1.02).toFixed(6)));
const tx = await client.predict.tx.mint(owner, descriptor, {
quantity,
maxCost,
maxProbability,
});
return { tx, quote, lower, upper };
}
Error codes
order validates range shape and quantity:
| Code | Constant | Cause |
|---|---|---|
0 | EInvalidOrderId | The packed ID sets a bit at or above 132. |
1 | EInvalidTick | A tick exceeds the 30-bit field or pos_inf_tick. |
2 | EInvalidRange | lower_tick >= higher_tick, or the range is the fully open (0, pos_inf_tick]. |
3 | EInvalidQuantity | Quantity is zero, not a whole 10_000 lot, above 4_294_967_295 lots, or a replacement is not strictly smaller. |
4 | EInvalidSequence | The sequence exceeds 40 bits. |
strike_exposure validates grid admission and exposure bookkeeping:
| Code | Constant | Cause |
|---|---|---|
0 | EInvalidCloseQuantity | A live close asks for more than the order's quantity. |
1 | EInvalidAdmissionTick | A finite boundary is neither on the admission grid nor equal to the market's reference_tick. |
2 | EInvalidReferenceTick | A reference tick resolves to 0 or to at least pos_inf_tick. |
3 | EReferenceTickAlreadySet | A call records a different reference tick after one is already set. |
4 | ETermsExposureMismatch | Terms priced against another market's exposure book. |
5 | EMintQuantityBelowMin | The sized or budgeted quantity is below min_quantity. |
6 | EInvalidInventoryImpactScale | The market's inventory-impact scale is zero at construction. |
The payout index behind strike_exposure is strike_payout_tree, and one of its codes is a mint abort:
| Code | Constant | Cause |
|---|---|---|
0 | EInsufficientPayoutQuantity | A close removes more payout than the tree holds at that boundary. |
1 | EMaxPayoutTreeNodes | The mint would add a new finite boundary tick when the market's index already holds 960 nodes. |
2 | ENonMonotonePrice | The pricer returned a probability that does not fall as the strike rises. |
3 | EStaleValuationSnapshot | A flush read a payout snapshot from an earlier flush. |
4 | ESnapshotSeqNotIncreasing | A flush activated a snapshot whose sequence did not advance. |
Each distinct finite boundary tick in a market costs one node, and a range with 2 new finite boundaries costs 2. The 960 cap is the valuation budget for a single market, so on a busy market a mint at a fresh strike can abort with EMaxPayoutTreeNodes while a mint at a strike some other position already uses succeeds. Codes 3 and 4 belong to the pool flush, and a mint or redeem cannot reach them.
A third module gates a mint on strike and size policy rather than on shape. strike_exposure_config holds the entry-probability band and the min_premium floor, and its 2 trader-facing codes, EEntryProbabilityOutOfBounds and EPremiumBelowMinimum, are the ordinary failure modes of a first mint. That table lives with the mint call, in Mint admission errors. The list of ways a well-formed mint aborts ends with 2 expiry_market codes: EMintCostAboveMaxPayout, code 11, when the all-in cost of the quote exceeds the quantity it would pay, which a deep in-the-money strike plus fees can reach; and protocol_config::ETradeWindowClosed, code 7, inside the no-trade window before expiry.
Error numbers repeat across modules, so the number alone identifies nothing: resolve an abort against the module named in the abort location. Code 3 is EInvalidQuantity in order, EReferenceTickAlreadySet in strike_exposure, EPremiumBelowMinimum in strike_exposure_config, EStaleValuationSnapshot in strike_payout_tree, and EMintProbabilityAboveMax in expiry_market. A reader who looks up 3 in the wrong table gets a plausible and wrong answer every time.
For the mint and redeem calls these ticks feed, see Predict. For cadence configuration and market creation, see Registry.