DeepBook Predict Testnet Workflow
A complete DeepBook Predict flow covers pricing a market board, creating an account, quoting and minting directional and two-sided range positions, closing before and after settlement, and running the queued liquidity provider flow. It continues from the DeepBook Predict quickstart, which covers install, funding, and a first mint.
Every transaction and every read on this page goes through the /predict subpath of @mysten/deepbook-v3, version 2.3.0 or later. The SDK carries both the deepbook-predict-mainnet and deepbook-predict-testnet deployments, and the samples select one with a single NETWORK constant that defaults to 'testnet'. The walkthrough runs on Testnet, where the quote coin is a mintable test coin and markets trade every minute. Set NETWORK to 'mainnet' to point the same code at Mainnet, where the quote coin is native USDC. The SDK builds transactions and never signs them, and every read it exposes runs against the Sui client core API, so no indexer or server sits on the critical path. The samples live in the examples/deepbook-predict package, which type-checks with npm run build (tsc --noEmit). This document does not execute them, so run the manual verification steps before you rely on the write paths.
As of 2026-09-10 the Mainnet deployment is onchain with its configuration in place, but its pool holds only the bootstrap minimum and no market has been tradable there yet. Read read.markets(), read.pool(), and the ProtocolConfig object on the network you target rather than assuming its state. Package IDs, object IDs, and function signatures on these pages belong to the 2 current deployments.
- Prerequisites
- Node.js 22 or later, with
@mysten/deepbook-v32.3.0 or later and its@mysten/sui2.30.0 or later peer dependency installed. See the Sui TypeScript SDK for client setup. - A Testnet address funded with SUI for gas from a Sui Testnet faucet. Hold at least 1 SUI as a working reserve.
- Test USDC from the DeepBook Predict Testnet token request form. Wallets display it as DUSDC. It never pays gas, so you need SUI as well. On Mainnet the quote asset is native USDC and there is no faucet.
- The deployment identifiers from Contract Information.
Configuration and client
Keep every deployment-specific identifier in one configuration block, select the network there, and assert the deployment name at startup. A later SDK release can intentionally move a network to a newer deployment, so an unchecked upgrade silently repoints your application.
import { getConfig, getDeployment, getUnits } from '@mysten/deepbook-v3/predict';
// The SDK carries a deployment record for Testnet and for Mainnet, so `getConfig`
// resolves either. This constant is the single place these examples select a
// network. Change it here and every other file follows. They default to Testnet,
// where the quote coin is a mintable test coin; on Mainnet it is native USDC.
export const NETWORK = 'testnet' as 'testnet' | 'mainnet';
export const FULLNODE_URL =
NETWORK === 'mainnet'
? 'https://fullnode.mainnet.sui.io:443'
: 'https://fullnode.testnet.sui.io:443';
// One underlying is live on this deployment.
export const UNDERLYING = 'BTC';
// The SDK carries the IDs of whichever deployments its release was cut against.
// Assert the name at startup, so a later SDK release that moves a network to a
// new deployment fails loudly here rather than quietly trading against a
// deployment these examples were never checked against.
export const EXPECTED_DEPLOYMENT = {
testnet: 'deepbook-predict-testnet',
mainnet: 'deepbook-predict-mainnet',
}[NETWORK];
export const DEPLOYMENT = getDeployment(NETWORK);
if (DEPLOYMENT.deployment !== EXPECTED_DEPLOYMENT) {
throw new Error(
`Expected DeepBook Predict deployment ${EXPECTED_DEPLOYMENT}, got ` +
`${DEPLOYMENT.deployment} (chain ${DEPLOYMENT.chainId}, ` +
`deepbookv3 commit ${DEPLOYMENT.sourceCommit}).`,
);
}
// Package IDs, the shared registry, protocol config, and pool vault objects, the
// quote coin type, and the per-underlying oracle IDs all come from the SDK, so
// no deployment identifier is hardcoded in these examples. Always read the quote
// coin from `CONFIG.quoteCoinType`: on Mainnet it is native USDC, and on Testnet
// it is a test coin with the same `usdc::USDC` module path that displays as DUSDC.
export const CONFIG = getConfig(NETWORK);
// Scale constants the deployment owns: position quantities are whole
// `positionLotSize` lots, amounts are `quoteCoinDecimals`-decimal USDC, and
// probabilities, prices, and rates are fixed point at `fixedPointScale`.
export const UNITS = getUnits(NETWORK);
getDeployment('testnet') returns the deployment name deepbook-predict-testnet, the chain ID 4c78adac, and the source commit the SDK generated its configuration from. getDeployment('mainnet') returns deepbook-predict-mainnet on chain 35834a8a. Any other network name throws a plain Error. getUnits(network) returns the unit constants, which are identical on both networks: a fixed-point scale of 1000000000 for probabilities, prices, and rates, 6 decimals for both the quote coin and position quantities, and a position lot size of 10000 raw units, which is $0.01 of payout.
getConfig(network).quoteCoinType is the one value to read rather than assume. Both networks use the usdc::USDC module path, but the Mainnet type lives in Circle's native USDC package and the Testnet type in a test coin package that Predict publishes.
Register the Predict extension on any client that exposes the core API. The following example uses the gRPC client.
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { predict } from '@mysten/deepbook-v3/predict';
import { FULLNODE_URL, NETWORK } from './config.js';
// `$extend` registers the Predict facade on the Sui client, so everything below
// reaches it at `client.predict`. Any client exposing the core API works,
// whether gRPC or JSON-RPC.
export const client = new SuiGrpcClient({
network: NETWORK,
baseUrl: FULLNODE_URL,
}).$extend(predict({ network: NETWORK }));
// Reads run through the client's own transaction simulation and object reads, so
// `client.predict.read.*` needs neither an indexer nor a Predict server.
//
// The SDK never signs and never holds keys. Every `client.predict.tx.*` builder
// returns a `Transaction` for a wallet, dapp-kit, or your own signer to execute.
// Execute with events included whenever you intend to decode a receipt, because
// the decoders read the events' canonical BCS bytes.
predict({ network }) lands the extension on client.predict, which exposes 3 groups. tx builds ready-to-sign transactions, read runs simulations and object reads, and decode parses receipts out of an executed transaction's events with no network call at all.
Every write below asks the account owner to sign. An application that would rather not prompt a wallet for each trade can delegate to a session key instead. A session key carries authority over everything the account holds, including its whole quote balance and every open position, so decide its scope and lifetime before you ship it. See Sessions.
Errors the SDK raises
Almost every failure you handle in application code falls under 2 typed errors:
PredictInputError: Client-side validation rejected the call before it built anything. Common causes are an unknown underlying, a strike off the tick or admission grid, a quantity that is not a whole lot, an amount carrying more decimals than the coin allows, and a range whoseloweris not belowupper.PredictMoveError: A simulation hit a Move abort. It carriesmodule, the exactcode, andabortName, theE-prefixed constant name.abortNameisnullover JSON-RPC, because only gRPC and GraphQL surface the clever-error constant name.
Resolve module before you look up code. Error numbers restart at 0 in every module, so the same number means something different depending on where it came from. Find the page for a Move abort maps each module to the reference page carrying its table.
Both SDK quote methods dry-run the identical transaction their matching builder produces, against the real account and the real fee path, so they raise the same errors the write would, insufficient balance included. Treat a successful SDK quote as a preflight. The Move expiry_market::quote_mint is a different thing with a similar name: it prices only, and it checks no account, no slippage cap, and no exposure capacity.
Understand the oracle
Predict owns no oracle. Pricing inputs live in the separate propbook package as 3 shared objects per underlying: a PythFeed holding exact-timestamp spot observations, a BlockScholesValueStore holding spot and forward observations, and a BlockScholesSVIStore holding the stochastic volatility inspired (SVI) volatility surface. One underlying is live on both networks, BTC, with propbook underlying ID 1.
Every priced call rebuilds a transaction-local Pricer from those objects through expiry_market::load_live_pricer. Pricer has copy and drop but not store, so it never persists between transactions, and it binds to exactly one ExpiryMarket. The SDK resolves the oracle objects from the deployment configuration for every call it builds, so on those paths you pass no oracle object ID yourself. A call the SDK does not build takes them explicitly: expiry_market::try_settle is the one this page asks you to make, and it takes the OracleRegistry, the PythFeed, and the BlockScholesValueStore.
Freshness bounds are protocol configuration, and pricing aborts on a stale input. Both deployments' initial configuration requires the Pyth spot and the Block Scholes spot and forward within 2 seconds, and the Block Scholes SVI surface within 60 seconds. Read the live values from the ProtocolConfig shared object rather than assuming them, because the deployment manifest records only an initial snapshot.
Discover markets
read.markets() returns every active market, which means live and not yet settled. Each entry describes one ExpiryMarket shared object:
| Field | Meaning |
|---|---|
id | The ExpiryMarket shared object ID |
expiryMs | Expiry as a Unix millisecond timestamp, typed bigint |
tickSize | Strike grid granularity in USD |
admissionTickSize | The coarser grid a numeric strike must land on |
mintPaused | Whether minting is paused for this market |
referencePrice | The window's anchor strike in USD, or null until the market records its reference tick |
import type { ActiveMarket, MarketSummary } from '@mysten/deepbook-v3/predict';
import { client } from './client.js';
import { UNDERLYING } from './config.js';
// Markets are created on a fixed cadence and every expiry is an absolute
// timestamp, so never hardcode one. Read the live board and take an expiry from
// it. `read.markets()` returns the pool's active markets, which means live and
// not yet settled: settlement is permissionless and unrewarded, so a market that
// is past its expiry but that nobody has settled is still in this list, and
// quoting against it aborts.
export async function liveMarkets(): Promise<ActiveMarket[]> {
const markets = await client.predict.read.markets();
// `expiryMs` is a bigint, so order it by comparison rather than subtraction.
return [...markets].sort((a, b) =>
a.expiryMs < b.expiryMs ? -1 : a.expiryMs > b.expiryMs ? 1 : 0,
);
}
// A market with enough life left to quote, sign, and land a transaction.
//
// Taking the soonest expiry is a trap. On the one-minute cadence the entry
// probability converges toward 0 or 1 in the closing seconds, so a quote taken
// there is stale before the mint executes and the `maxCost` cap then aborts the
// trade. Requiring a minimum time to expiry is what makes the quote-then-cap
// flow hold. Raise `minTtlMs` for a wallet flow that waits on a human.
//
// `referencePrice` is null for a short time at the start of a window. Anyone can
// seed it with the permissionless `expiry_market::set_reference_tick`; this
// helper skips those markets instead.
export async function tradeableMarket(minTtlMs = 30_000): Promise<ActiveMarket> {
const now = Date.now();
const open = (await liveMarkets()).filter(
(m) =>
!m.mintPaused &&
m.referencePrice !== null &&
Number(m.expiryMs) - now >= minTtlMs,
);
if (open.length === 0) {
throw new Error(
`No DeepBook Predict market has ${minTtlMs} ms or more left before expiry.`,
);
}
return open[0];
}
// One market's on-chain state, including its live NAV. Returns null when no
// market exists at that expiry.
export async function marketState(expiryMs: bigint): Promise<MarketSummary | null> {
return client.predict.read.market({ underlying: UNDERLYING, expiryMs });
}
// A numeric strike must be a whole multiple of the market's `admissionTickSize`,
// which is deliberately coarser than `tickSize` and varies by cadence. Round the
// target onto that grid rather than assuming a step: an off-grid strike throws
// `PredictInputError` when the transaction is built. The market's own
// `referencePrice` is the single finite strike the chain admits off-grid.
export function admissibleStrike(market: ActiveMarket, targetUsd: number): number {
const snapped = Math.round(targetUsd / market.admissionTickSize) * market.admissionTickSize;
// Trim binary-float residue before returning. Strikes scale by 1e9 and the SDK
// throws when a value carries more than nine decimals, which the multiply above
// produces for sub-dollar steps: at a 0.1 step it lands on values such as
// 96519.90000000001. Both enabled cadences use a 1 USD step today, so this is
// defensive, but the step is mutable protocol state and is read from the market.
return Number(snapped.toFixed(9));
}
read.market({ underlying, expiryMs }) returns the same fields plus nav, the market's current net asset value, and returns null when no market exists at that expiry.
Both networks enable 2 cadences for BTC, 1m and 5m, with 2 expiries of each live at once. The configuration also lists the 1h, 1d, 1w, and 1mo cadences, all disabled. Testnet creates a new 1m market every minute. tickSize is $0.01 and admissionTickSize is $1 on both enabled cadences, so every finite strike lands on a whole dollar.
The soonest expiry is a poor default for 3 reasons, and the sample's tradeableMarket(minTtlMs = 30_000) handles the first 2:
- Trading window: Trading closes before expiry.
no_trade_window_ms, 2 seconds on both deployments, is a hard stop: a quote, mint, or live redeem inside the last 2 seconds before expiry aborts inprotocol_configwith code7,ETradeWindowClosed. An expired market that nobody has settled is still in the active list, and quoting against it aborts too. - Closing seconds: A market can be too close to expiry to trade well before that. On the 1-minute cadence the entry probability converges hard in the closing seconds, so a quote taken there is stale before the mint lands and the cost cap aborts the trade. Requiring a minimum time to expiry is what makes the quote-then-cap flow hold, and 30 seconds is a floor rather than a recommendation for a flow that waits on a human.
- Unfunded markets: A brand-new market opens with zero working cash. Nobody can mint against it until pool capital funds it, and
plp::rebalance_expiry_cashis what moves that cash across. Anyone can call it. A mint that aborts on a market that looks otherwise healthy is usually one nobody has funded yet.
Ticks, strikes, and position shape
A position is a half-open range on an absolute integer tick grid, (lower_tick, higher_tick]. Direction is a special case of that range rather than a separate concept: a down position is (0, K] and an up position is (K, 1073741823], where 0 is the negative-infinity sentinel tick and 1073741823 is the positive-infinity sentinel. The protocol rejects the fully open range.
The SDK hides the tick arithmetic behind a market descriptor with 2 arms:
- Directional:
{ underlying, expiryMs, side: 'up' | 'down', strike }, wherestrikeis a USD number or the literal'reference'. - Two-sided range:
{ underlying, expiryMs, side: 'range', lower, upper }, where both bounds are finite USD numbers andlowersits belowupper.
An optional marketId on either arm pins the exact ExpiryMarket and skips the registry lookup. The SDK validates the ID and rejects a pin whose expiry disagrees with the descriptor.
A numeric strike must land on the market's admissionTickSize grid, not just the finer tickSize grid. The sample's admissibleStrike helper rounds a target onto that grid and trims the binary-float residue the multiply leaves behind, which matters because the SDK throws on a value carrying more than 9 decimals.
The market's referencePrice is the one finite off-grid boundary the chain admits, and strike: 'reference' selects it. That literal works on the directional arm only, and it throws while the market's reference price is still null. A keeper normally seeds each window, but expiry_market::set_reference_tick is permissionless, so you can seed it yourself instead of waiting. It reads the exact Pyth spot at the market's reference timestamp and derives the tick from it, and a repeat call with the same tick succeeds and emits nothing.
Price a whole board from one chain read
read.price({ underlying, expiryMs, strike }) returns { up, down } as probabilities between 0 and 1 for a single strike. It takes no side and always returns both.
read.pricer({ underlying, expiryMs }) loads the pricer once and hands back a local calculator, so a board of dozens of strikes costs one chain read:
| Call | Returns |
|---|---|
pricer.up(strike) | Probability that settlement lands above strike |
pricer.down(strike) | Probability that settlement lands at or below strike |
pricer.range(lower, higher) | Probability mass in (lower, higher], floored at 0 |
pricer.strikeAtProbability(p) | The strike whose up probability is p, or null when no crossing exists within 64 percent of the forward |
pricer.forward | The forward price the surface anchors on |
pricer.asOf | Source timestamps for the Pyth spot and the Block Scholes spot, forward, and SVI observations |
Every value here is a decimal float rather than chain fixed-point, and every calculation runs locally. Pass a lower of 0 or below for negative infinity and Infinity for an open upper bound. Check asOf before you render a board, because a pricer built from an observation that later goes stale still prices locally without complaint.
Set up the account
Each owner has exactly one Predict account: a shared AccountWrapper object holding an Account. The wrapper holds your quote coin balance, your PLP balance, and Predict's per-account data. A position is a packed order ID recorded against that account rather than an object in your wallet, so scanning owned objects never finds one.
client.predict.tx.createManager() takes no arguments and returns a transaction that creates the wrapper and shares it. The method name carries legacy DeepBook balance-manager naming; the object it creates is an account wrapper.
import type {
CreateManagerReceipt,
DecodableTransactionResult,
} from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
// Each trader holds one canonical account: a shared `AccountWrapper` holding an
// `Account`. The builder keeps the legacy DeepBook balance-manager name, but the
// object it creates is the account wrapper.
export function createAccount(): Transaction {
return client.predict.tx.createManager();
}
// The wrapper ID is derived from the owner address, so you can compute it before
// the transaction lands. No chain read.
export function accountWrapperId(owner: string): string {
return client.predict.wrapperIdFor(owner);
}
// First-time setup in a single PTB: create the wrapper, deposit, and share it.
// `owner` must be the address that signs, because the wrapper is derived from
// the transaction sender. This aborts if the account already exists, so use it
// only on the create path.
export function createAndFund(owner: string, amountUsdc: number): Transaction {
return client.predict.tx.deposit(owner, amountUsdc, { create: true });
}
// Fund an account that already exists. The USDC is sourced from the owner's
// coin objects and address balance together.
export function deposit(owner: string, amountUsdc: number): Transaction {
return client.predict.tx.deposit(owner, amountUsdc);
}
// Take USDC back out of the account. It lands in the owner's address balance;
// pass `{ toCoinObject: true }` when you need a discrete coin object instead.
export function withdrawToWallet(owner: string, amountUsdc: number): Transaction {
return client.predict.tx.withdraw(owner, amountUsdc);
}
// The decoders are pure and touch no network. Execute the create transaction
// with events included, then read the IDs off the receipt.
export function decodeCreated(result: DecodableTransactionResult): CreateManagerReceipt {
return client.predict.decode.createManager(result);
}
// The account's internal custody balance in USDC, as a decimal number. This is
// the balance a mint is debited from, not the owner's wallet balance.
export async function accountBalance(owner: string): Promise<number> {
return client.predict.read.balance(owner);
}
The wrapper address derives from the account registry root, so you do not need a transaction result to know it. client.predict.wrapperIdFor(owner) computes it offchain with no chain read, and that ID is the argument every Move call takes. After execution, decode.createManager(result) reads the same wrapperId out of the events alongside accountId, owner, and selfOwned.
One owner derives 2 addresses from that registry, and they are not interchangeable. The wrapper is the shared object every Move call takes. The canonical account is the identity that app data hangs off, that events report as account_id, and that indexed read services key on. client.predict derives only the wrapper, through wrapperIdFor. You have 3 routes to the account ID: new SessionsContract(getSessionsConfig(network)).deriveAccountId(owner) on the @mysten/deepbook-v3/sessions subpath, the accountId field on decode.createManager(result), or the account_id field on any order event. Sessions covers both derivations and the dynamic field that hangs off the account.
Creating a second account for the same owner aborts. To create and fund in a single transaction, tx.deposit(owner, amountUsdc, { create: true }) composes the creation, the deposit, and the share. That form runs no pre-check, so it aborts when the account already exists, and owner must be the signing address because the registry derives the wrapper from the transaction sender.
Funding and reading the account takes 3 calls:
- Deposit:
tx.deposit(owner, amountUsdc)sources the quote coin from your coin objects and your address balance. - Withdraw:
tx.withdraw(owner, amountUsdc)sends the quote coin to the owner's address balance. Pass{ toCoinObject: true }to receive a coin object instead. - Read the balance:
read.balance(owner)returns the account's quote balance as a decimal number.
Read state
Read live state through the SDK first. Every read method runs against the full node for the selected network and needs no indexer, and together they cover everything this page acts on:
| Call | Returns |
|---|---|
read.markets() | The active market set, live and not yet settled |
read.market({ underlying, expiryMs }) | One market with its nav, or null |
read.balance(owner), read.plpBalance(owner) | The account's quote balance as a decimal and its PLP shares as a raw bigint |
read.positions(owner) | Every open position as marketId and orderId pairs, walked from the account's dynamic fields |
read.pool() | Pool aggregates: plpTotalSupply, idleUsdc, and the 2 queue depths |
read.quoteMint, read.quoteRedeem | Exact dry runs of the matching builders |
For a live tape, stream events from the full node over gRPC and back it with a ListEvents backfill, as Contract Information shows.
Indexed read services for the previous Testnet deployment
As of 2026-09-10 no indexed read service exists for deepbook-predict-testnet or for Mainnet. The 3 public read-only JSON services that do exist index the previous-generation predict-8-21 Testnet deployment: their responses carry that deployment's package and object IDs, and they know nothing about accounts, positions, or markets on the current ones. They are indexed views rather than transaction authorities, they are not part of any audited manifest, and their operators can retire or repoint them independently of the onchain packages. This section stays for readers still working against predict-8-21 and as the shape a future service is likely to follow.
| Service | Base URL | Primary data |
|---|---|---|
| Predict | https://predict-server-v4.testnet.mystenlabs.com | Markets, market state, positions, vault state, protocol events, indexed configuration |
| Propbook | https://propbook-server-v4.testnet.mystenlabs.com | Oracle bindings, Pyth observations, and Block Scholes spot, forward, and SVI observations |
| Account | https://account-server-v4.testnet.mystenlabs.com | Account custody state, balances, activity, portfolio, and app authorizations |
Every service exposes /status, which reports the latest onchain checkpoint and one entry per indexed pipeline with its indexed checkpoint and time lag. Treat a non-OK status, or an unexpectedly old pipeline your feature depends on, as incomplete recent data rather than as an empty application state:
$ curl https://predict-server-v4.testnet.mystenlabs.com/status
Market discovery returns future, unsettled market-creation records ordered by expiry. Use expiry_market_id for market-scoped reads, pool_vault_id for vault reads, and propbook_underlying_id to join the market to propbook:
$ curl 'https://predict-server-v4.testnet.mystenlabs.com/markets?active=true&limit=50'
$ MARKET_ID=0x...
$ curl "https://predict-server-v4.testnet.mystenlabs.com/markets/${MARKET_ID}/state"
$ curl "https://predict-server-v4.testnet.mystenlabs.com/markets/${MARKET_ID}/open-interest"
Market state returns the creation record plus the latest reference tick, the mint-pause state, and the settlement once those components exist. Oracle observations key on the propbook object IDs that GET /oracle-bindings returns for that deployment:
$ curl "https://propbook-server-v4.testnet.mystenlabs.com/oracle-bindings"
$ PYTH=0x... VALUES=0x... SVI=0x...
$ curl "https://propbook-server-v4.testnet.mystenlabs.com/oracles/${PYTH}/pyth/latest"
$ curl "https://propbook-server-v4.testnet.mystenlabs.com/oracles/${VALUES}/block-scholes/spot?limit=10"
$ curl "https://propbook-server-v4.testnet.mystenlabs.com/oracles/${VALUES}/block-scholes/forward?limit=10"
$ curl "https://propbook-server-v4.testnet.mystenlabs.com/oracles/${SVI}/block-scholes/svi?limit=10"
Judge vendor freshness from each observation's source timestamp and landing time from its checkpoint timestamp. Do not sort observations by source timestamp alone, because an older source observation can land later.
Account and position paths key on the canonical account ID, not on the shared AccountWrapper object ID and not on the wallet owner address. Derive it with deriveAccountId, or read it off any order event's account_id field.
$ ACCOUNT_ID=0x...
$ curl "https://account-server-v4.testnet.mystenlabs.com/accounts/${ACCOUNT_ID}/portfolio"
$ curl "https://account-server-v4.testnet.mystenlabs.com/accounts/${ACCOUNT_ID}/balances"
$ curl "https://account-server-v4.testnet.mystenlabs.com/accounts/${ACCOUNT_ID}/activity"
$ curl "https://predict-server-v4.testnet.mystenlabs.com/accounts/${ACCOUNT_ID}/positions?status=open&limit=100"
Passing a wrapper ID here fails silently. The services answer 200 with an empty array rather than rejecting the ID, so a wrong ID is indistinguishable from an account holding nothing. Check any empty result against an account you know holds positions before you build on it. The same silence hides a deployment mismatch: an account on the current Testnet deployment also reads as empty here.
The indexed views have 3 more paths: GET /apps on the account service lists the app authorizations that the account registry records, GET /vaults/:pool_vault_id/state on the Predict service returns the pool's current block alongside latest_flush, and GET /config on the Predict service returns trading_paused, protocol_config_id, max_lp_pool_value, and max_lp_pool_value_is_unbounded. Take every path from these tables rather than guessing at one. The plausible variants /vault, /pool, /protocol-config, and /accounts/:account_id/apps all return 404.
All 3 services share 4 conventions:
- Numbers arrive as strings: Postgres
NUMERICvalues serialize as JSON strings. Parse monetary, quantity, probability, price, rate, and large identifier fields with decimal or bigint tooling rather thanparseFloat. - Timestamps are milliseconds: Onchain and event timestamps use Unix milliseconds unless an endpoint documents seconds. Raw event pages window on
from_ms, inclusive, andto_ms, exclusive, while named history endpoints usestart_timeandend_timein Unix seconds. - Order IDs are market-scoped: Packed order IDs are decimal
u256strings and are unique only inside one expiry market, so identify a position byexpiry_market_idandorder_idtogether. - Raw events page oldest-first:
GET /eventslists the allowlisted event resources and their filter profiles. Page one resource withGET /events/RESOURCE, then follow the opaquepage.next_cursorwith every filter unchanged untilpage.has_next_pageis false. Unknown identifiers returnnullcomponents or empty arrays rather than a404. The listing also carries a few resources no deployment emits, left over from contract features that no longer exist:liquidated-order-redeemed,deep-staked,deep-unstaked, andtrading-loss-rebate-claimed. Requesting one returns503rather than an empty page, so treat the 4 event modules in Contract Information as the list of what the contracts actually emit.
Deposit and mint a directional position
A directional position pays $1 per contract when settlement lands inside its range, and nothing otherwise. quantity is therefore the maximum payout in USD, and it must be a whole $0.01 lot.
The lot size is the granularity of a quantity, not a size you can trade. strike_exposure_config::assert_mint_admission rejects any mint whose premium falls below 1 USDC with code 3, EPremiumBelowMinimum, and the premium is quantity multiplied by the quoted entryProbability. Because max_entry_probability caps that probability at 0.99, no market accepts a quantity below about 1.02, and the floor climbs as the strike moves out of the money: a strike quoting near 50 percent needs roughly 2 contracts, and a strike quoting at 0.08 needs 12.5. Size by the product, not by the grid, and quote before you pick a quantity.
Quote before you mint. read.quoteMint(owner, descriptor, { quantity }) dry-runs the identical transaction tx.mint builds, against the real account and the real fee path, so it doubles as a price preview and a preflight, and its fee numbers are exact rather than estimated:
| Field | Meaning |
|---|---|
entryProbability | Fill probability between 0 and 1, per $1 of payout, and what maxProbability caps |
premium | The premium alone, with no fees |
fees | Exact trading, subsidy, builder, penalty, referral, and inventoryImpact components |
cost | The all-in account debit, which is what maxCost caps |
quantity | The payout the quote priced |
raw | Exact bigint chain amounts for premium, cost, quantity, and entryProbability only |
The fee names differ from their onchain names in 2 cases. penalty is the congestion surcharge, penalty_fee onchain, and it ships disabled on both deployments. subsidy is the fee incentive subsidy, fee_incentive_subsidy onchain. Design explains what each component charges for.
The fee breakdown is not in raw, which carries only the 4 fields above. Read the exact fee amounts from decode.mint(result).raw after execution instead.
fees.referral is not an extra debit and never changes cost. Its basis is the trading fee net of the incentive subsidy, plus the congestion surcharge, and the protocol computes it only after the all-in cost is final, then splits it out of the payment you have already made. The Move MintQuote has no referral field at all for that reason. Pass cost, never premium, when you set a cap.
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 };
}
Mint slippage caps default to uncapped. Omitting maxCost and maxProbability sends U64_MAX for each, which leaves the mint genuinely uncapped against a price move between the quote and execution. The 2 caps bound different things: maxCost bounds the all-in account debit, and maxProbability bounds the fill price. Always quote first and pass both. Round maxCost to at most 6 decimals, because a value carrying more decimals than the quote coin throws PredictInputError before the SDK builds the transaction.
tx.mint mints an exact payout quantity, so the premium alone cannot exceed quantity. The protocol charges fees on top of it, and maxCost is what bounds the total. A mint whose all-in cost would exceed quantity aborts with EMintCostAboveMaxPayout. tx.mintAmount is the genuinely open-ended one: see the warning below it.
decode.mint(result) returns the receipt. Persist orderId. It is the only handle for closing or claiming the position, and nothing onchain enumerates an account's orders for you. The receipt also carries lowerTick, higherTick, entryProbability, quantity, premium, and the exact fee breakdown, plus a raw block giving the quantity, the premium, the entry probability, and every fee component as exact bigint chain amounts.
To size by budget instead of by payout, tx.mintAmount takes spend as a premium budget, minQuantity as a payout floor, and the same maxCost ceiling. It takes no maxProbability.
maxCost is optional on tx.mintAmount, and that is the more dangerous default of the 2 builders. The protocol charges fees on top of spend, so maxCost is the only bound on the whole withdrawal. Omit it and the call builds fine, sends U64_MAX, and leaves the debit unbounded. The onchain guard does not save you: mint_exact_amount aborts with EMintCostCapRequired only when max_cost is exactly 0, which U64_MAX is not. The SDK does reject a maxCost at or below zero before it builds anything, but nothing rejects an absent one. Always pass a maxCost derived from a fresh quote.
Mint a two-sided range position
A two-sided range pays out when settlement lands in the half-open band (lower, upper], left-open and right-closed. It uses the same builder, quote, and close path as a directional position, with the range arm of the descriptor.
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 };
}
Both bounds are finite USD numbers that must land on the market's admission grid, and lower must sit below upper. strike: 'reference' has no meaning here, because the range arm carries no strike field. read.quoteMint accepts a range descriptor unchanged, and pricer.range(lower, upper) prices the band locally before you commit to it. The sample passes maxProbability alongside maxCost, the same as the directional one, because a cost cap alone still lets the fill price move.
Redeem and settlement
Closing a position takes one of 2 paths, and which one applies depends on whether the market has settled. The following sample quotes and closes a live position, then claims a settled one.
import type {
CloseOptions,
DecodableTransactionResult,
MarketDescriptor,
OpenPosition,
RedeemQuote,
RedeemReceipt,
} from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { UNDERLYING } from './config.js';
// Close part or all of a live position. Reuse the descriptor the position was
// minted with: the close needs it only to resolve the market object, and the
// order ID identifies the position itself.
//
// The quote is the only protection available on a live close. `tx.redeem` sends
// `minProbability: 0` and `minProceeds: 0` unconditionally, and the facade
// exposes no option to raise either floor, so the proceeds are not capped
// against a price move between the quote and execution. Quote immediately before
// closing and treat the figure as an estimate.
export async function closeLive(
owner: string,
descriptor: MarketDescriptor,
opts: CloseOptions,
): Promise<{ tx: Transaction; quote: RedeemQuote }> {
const quote = await client.predict.read.quoteRedeem(owner, descriptor, opts);
const tx = await client.predict.tx.redeem(owner, descriptor, opts);
return { tx, quote };
}
// A partial close retires the old order ID and issues a new one, reported as
// `replacementOrderId`. It is null when the position closed in full. Store the
// replacement, or the next close targets an order that no longer exists.
export function decodeClose(result: DecodableTransactionResult): RedeemReceipt {
return client.predict.decode.redeem(result);
}
// Claim a position whose market has settled. The claim closes the order in full,
// so it takes no quantity, and it needs only the market coordinates rather than
// a side or a strike.
export async function claimSettled(params: {
owner: string;
expiryMs: bigint;
marketId: string;
orderId: bigint;
}): Promise<Transaction> {
const { owner, expiryMs, marketId, orderId } = params;
return client.predict.tx.claimSettled(
owner,
{ underlying: UNDERLYING, expiryMs, marketId },
{ orderId },
);
}
// Every open position for an owner, read straight from the account's on-chain
// position table. Use it to recover order IDs from a cold start.
export async function openPositions(owner: string): Promise<OpenPosition[]> {
return client.predict.read.positions(owner);
}
Close a live position
read.quoteRedeem(owner, descriptor, { orderId, quantity }) returns the net proceeds, the gross amount, an exact fee breakdown covering trading, builder, penalty, and inventoryImpactRebate, plus quantityClosed and remaining. tx.redeem builds the matching transaction. A live close is subject to the same no-trade window as a mint, so it aborts inside the last 2 seconds before expiry; after expiry, wait for settlement and claim instead.
A partial close retires the order and issues a replacement. Read replacementOrderId from decode.redeem(result) and update your stored ID, otherwise your next close targets an order that no longer exists. A live close also fails when it lands in the same millisecond timestamp as the mint.
tx.redeem sends minProbability: 0 and minProceeds: 0 unconditionally, and the SDK exposes no option to raise either floor. A live close therefore accepts whatever the pricer returns at execution time. Quote immediately before you send, and treat the quoted proceeds as an estimate rather than a guarantee.
Settlement
Settlement is permissionless. Any address can call expiry_market::try_settle, which returns true when the market is now settled or was already settled, and false when it could not record a settlement. The call is idempotent, it returns false before expiry, and a pool flush in flight never blocks it.
Settlement needs an exact price at the market's expiry timestamp, and it looks for one in a fixed order:
- Exact Pyth spot:
try_settlefirst looks for a Pyth observation at exactly the expiry timestamp. - Exact Block Scholes spot after a grace period: When Pyth has nothing at that timestamp, the call waits 30 seconds past expiry, then tries the exact Block Scholes minute-boundary spot.
A market with neither exact observation stays unsettled, and later calls keep retrying. No approximation substitutes for the exact price, because an expired unsettled market has no solvency-safe mark.
The SDK builds no try_settle, so this is the one call on this page you assemble yourself. It is generated-bindings work: a moveCall against expiry_market::try_settle in the Predict package, passing the ExpiryMarket, the ProtocolConfig, the propbook OracleRegistry, the PythFeed, the BlockScholesValueStore, and the clock, in that order. Those are the oracle objects the SDK otherwise resolves for you, so read their IDs from Contract Information or from OracleRegistry at runtime. The call returns a bool you can ignore, and Predict carries the full signature.
Settlement emits MarketSettled carrying the settlement_price and a settlement_source of 0 for Pyth or 1 for Block Scholes. Trigger on that event rather than on the expiry timestamp, because the gap between expiry and settlement has no fixed duration.
Claim a settled position
tx.claimSettled(owner, { underlying, expiryMs }, { orderId }) closes a settled position. It takes no quantity, because the deployed redeem_settled closes the order in full. decode.claim(result) returns the payout alongside the market, account, and order identifiers.
Permissionless redemption
expiry_market::redeem_settled_permissionless closes a settled position on the owner's behalf. It has 3 defining properties:
- Any address can call it: The function takes no account
Auth. It mints Predict's app authorization from the sharedAccountRegistryinstead, so the caller needs no capability, no relationship to the owner, and no signature from them. An admin can switch the path off by deauthorizing the Predict app on that registry. - It becomes available at settlement, not at expiry: The market must have settled. Calling on an unsettled market aborts with
EMarketNotSettled, and expiry alone does not settle a market. Calltry_settlefirst, or wait forMarketSettled. - The payout goes to the owner: The proceeds land in the owner's account, and only the owner can withdraw them. The caller pays gas and receives nothing.
The SDK does not build this call. tx.claimSettled builds the owner-authorized redeem_settled instead. Build the permissionless variant yourself as a moveCall against the Predict package, passing the ExpiryMarket, the AccountRegistry, the owner's AccountWrapper, the ProtocolConfig, the order ID, the accumulator root, and the clock. No Predict package declares an entry fun, so every integrator call is a public fun invoked from a programmable transaction block, and several of them return values you must consume in the same block.
To find positions worth closing, read.positions(owner) walks the account's dynamic fields and returns marketId and orderId pairs. An indexed service, where one exists for your deployment, returns the same set only when you key it on the canonical account ID.
Key an indexed positions path on the wrapper ID instead and the service answers 200 with an empty array. A redeemer that trusts the response reads the empty array as nothing to close, skips every open position, and reports success while doing no work. Derive the ID with deriveAccountId, take it from an event's account_id, or cross-check the count against read.positions(owner) before you act on an empty page.
Make any automated redeemer idempotent: the event stream delivers at least once, and another caller can close the same position between your check and your submission.
The protocol pays the caller no fee, rebate, or share of the payout, so no economic incentive exists for an unrelated party to run this. Treat it as infrastructure you run for your own users, or that an owner runs for themselves, and budget the gas as your own operating cost.
Liquidity provider flow
Liquidity providers supply the quote coin to the shared PoolVault and hold PLP, a proportional claim on pool value. The pool is the counterparty to every Predict trade, so PLP value tracks pool profit and loss rather than a fixed yield.
Supply and withdrawal are asynchronous. Neither call mints or burns PLP. Each one escrows your funds, returns a queue index, and waits for the next pool flush, which prices both queues against one pool-wide mark.
You cannot start a flush. Only a holder of a pool valuation capability starts one, through start_pool_valuation, and that transaction also snapshots every live market's pricer with snapshot_expiry_pricer and seals the snapshot. Anyone can then run value_expiry per market and finish_flush, which drains the queues at the frozen mark, and the whole flush must finish within 5 minutes of its snapshot, or the cap holder restarts it from scratch. That single frozen mark is what stops anyone from timing an entry or exit against a self-supplied oracle update. A flush in flight blocks neither trading nor queuing a new request, but a request queued after the snapshot waits for the next flush, and the flush blocks canceling a queued request until it finishes. Plan on filling at a price you do not control and cannot preview, bounded only by the floor you set.
Supply liquidity
tx.supplyPlp(owner, amountUsdc, { minPlpOut }) queues a supply request and escrows the quote coin. The minimum request is 10 USDC. minPlpOut is a floor on the shares the flush must mint for the request, in raw 6-decimal shares, and it is optional: omitted, the request accepts whatever the next flush quotes.
import type {
DecodableTransactionResult,
PlpRequestReceipt,
PoolSummary,
} from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
// Supplying to the pool queues a request rather than minting PLP on the spot.
// This transaction returns no PLP: the request fills at the next pool flush, at
// the single NAV that flush computes. `minPlpOut` is a floor on that mark, in raw
// six-decimal shares: a flush quoting fewer shares declines rather than filling
// smaller, and at the deployed attempt count of one the first miss cancels and
// refunds the request. Omit it to accept whatever the next flush quotes.
export function queueSupply(
owner: string,
amountUsdc: number,
minPlpOut?: bigint,
): Transaction {
return client.predict.tx.supplyPlp(owner, amountUsdc, { minPlpOut });
}
// The queue index is the handle for cancelling a request before it fills, and it
// exists only in the receipt. Execute with events included and keep it.
export function supplyRequestIndex(result: DecodableTransactionResult): bigint {
// `kind` is 'supply' here, and `amount` is in quote units.
const receipt: PlpRequestReceipt = client.predict.decode.plpRequest(result);
return receipt.index;
}
// Pool state. `supplyRequestsPending` and `withdrawRequestsPending` are queue
// lengths rather than amounts, and `plpTotalSupply` is raw six-decimal shares.
export async function poolState(): Promise<PoolSummary> {
return client.predict.read.pool();
}
decode.plpRequest(result) returns the receipt. Persist index. It is the handle for tx.cancelSupplyPlp(owner, index), which reclaims the escrow any time before a flush processes the request, except while a flush is in flight. The receipt's kind is 'supply' or 'withdraw', and amount is in quote units for a supply.
A floor is a decline, not a wait. lp_request_limit_flush_attempts is 1 on both deployments, so the first flush whose mark misses minPlpOut cancels the request and refunds the escrow rather than leaving it queued for a better mark. The same happens to a request the flush cannot execute at all, because the PLP price falls outside the executable band or the output rounds to zero shares. Handle a refund as a normal outcome.
A request you leave queued eventually fills, and the fill is what turns escrowed quote coin into PLP. Watch for vault_events::SupplyFilled: the flush emits one per filled request, carrying the usdc_amount consumed, the shares minted at that flush's mark, the fee, and the amount left queued when the fill was partial. The shares arrive at the account's receive address through the balance accumulator rather than as a coin in your wallet, but you do not have to chase them. read.plpBalance(owner) counts them straight away, because the Move account::balance behind it sums stored balance and undelivered accumulator funds together, and tx.withdrawPlp settles PLP into stored balance itself before it queues anything. vault_events::WithdrawFilled is the matching event on the way out, and RequestCancelled covers both a cancellation you made and a refund the flush issued.
Withdraw liquidity
tx.withdrawPlp(owner, shares, { minUsdcOut }) takes raw PLP shares as a bigint, not a USD amount. Read the balance with read.plpBalance(owner), which returns the same raw bigint, and pass it straight through. minUsdcOut is an optional floor on the quote coin the flush must pay for the whole request, in USD decimals, measured after the withdraw fee; at the deployed attempt count of one, a flush whose mark misses it cancels and refunds the request.
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
// Withdrawing from the pool is queued exactly as a supply is, and it takes raw
// PLP shares rather than a USD amount. `read.plpBalance` returns those shares
// directly, so exiting the whole position needs no conversion. `minUsdcOut` is a
// floor on the USDC the flush pays for the whole request, measured after the
// withdraw fee: a flush quoting less declines and, at the deployed attempt count
// of one, cancels and refunds the request. Omit it to accept the next mark.
export async function queueWithdrawAll(
owner: string,
minUsdcOut?: number,
): Promise<Transaction> {
const shares = await client.predict.read.plpBalance(owner);
// The chain rejects a request below one whole PLP, which is 1_000_000 raw at
// six decimals. Check it here rather than letting a dust holder take a Move
// abort out of a helper whose job is exiting the whole position.
const MIN_WITHDRAW_RAW = 1_000_000n;
if (shares < MIN_WITHDRAW_RAW) {
throw new Error(
`${owner} holds ${shares} raw PLP shares; the minimum withdrawal request is ${MIN_WITHDRAW_RAW}.`,
);
}
return client.predict.tx.withdrawPlp(owner, shares, { minUsdcOut });
}
// A queued request can be cancelled up to the flush that would fill it. The
// index comes from the request receipt: `decode.plpRequest(result).index`.
export function cancelQueuedWithdraw(owner: string, index: bigint): Transaction {
return client.predict.tx.cancelWithdrawPlp(owner, index);
}
// A queued supply cancels the same way, through its own builder.
export function cancelQueuedSupply(owner: string, index: bigint): Transaction {
return client.predict.tx.cancelSupplyPlp(owner, index);
}
Cancel a queued withdrawal with tx.cancelWithdrawPlp(owner, index), using the index from decode.plpRequest. The minimum withdrawal request is 1 PLP, which is 1000000 raw at 6 decimals, and the sample checks that floor itself rather than letting a dust holder take a Move abort. Both deployments' initial configuration charges a 0.2 percent withdrawal fee and no supply fee, and caps the pool at 500,000 USDC. Read the live rates from the ProtocolConfig shared object, because the manifest records only the initial snapshot.
Read vault state
read.pool() returns the pool aggregates:
| Field | Meaning |
|---|---|
plpTotalSupply | Total PLP in issue, as a raw bigint |
idleUsdc | The pool's idle quote balance as a decimal number, excluding cash funded into expiries |
supplyRequestsPending | The number of queued supply requests, not an amount |
withdrawRequestsPending | The number of queued withdrawal requests, not an amount |
There is no synchronous share price. The pool prices PLP only inside a flush, at the mark that flush freezes, so no read returns a price you could trade against. Use idleUsdc and the queue depths instead to judge how much of a withdrawal the pool can settle soon and how many requests sit ahead of yours. On Mainnet, as of 2026-09-10, plpTotalSupply is the 10 USDC bootstrap lock and both queues are empty.
Vault strategy considerations
These points follow from the protocol's documented mechanics, and they are not investment advice:
- You fill at the flush mark, not your own: Both queues price against one pool-wide net asset value frozen at the flush snapshot. That value moves between the moment you queue and the moment you fill, and no read previews it. A floor turns a bad mark into a refund rather than a fill.
- Idle cash bounds exits: The flush fills supplies before withdrawals, so quote coin supplied in a flush can pay that same flush's withdrawals. Cash already funded into an expiry is not directly redeemable until a rebalance or settlement returns it, so a large exit can fill across several flushes.
- PLP value moves with open positions: Each active expiry contributes its net asset value to the pool, priced from the oracle observations frozen at the snapshot, so your share value moves with trader positions and oracle prices even when you place no trade.
- The protocol enforces backing per expiry: Every expiry holds cash at least equal to its payout liability, and a per-expiry allocation cap snapshotted from cadence configuration bounds how much pool capital one expiry can put at risk.
- A queued request can come back refunded: The flush cancels and refunds, rather than holds, a request it cannot execute at the frozen mark or whose floor that mark misses, so handle a refund as a normal outcome rather than an error.
For the accounting model behind these limits, see Design.
Verify on Testnet
The write paths above type-check but this document does not execute them. To confirm the flow end to end, with NETWORK set to 'testnet':
- Fund a Testnet address with SUI, then confirm the balance with
sui client gas. - Request test USDC, then confirm the balance with
sui client balance, where it appears as DUSDC. - Confirm the configuration block resolves:
getDeployment('testnet').deploymentisdeepbook-predict-testneton chain4c78adac, andgetConfig('testnet').quoteCoinTypenames theusdc::USDCtype under package0xc028557a…. - Call
read.markets(). Confirm at least one market returns withmintPausedfalse, a non-nullreferencePrice, and at least 30 seconds left before its expiry. - Create the account, then confirm
client.predict.wrapperIdFor(owner)resolves withsui client object WRAPPER_ID. - Deposit test USDC, then confirm
read.balance(owner)reports the deposit. - Call
read.quoteMintwith a quantity whose premium clears 1 USDC, which meansquantitymultiplied byentryProbabilityabove 1. Mint withmaxCostderived from the quote'scost. Confirm anOrderMintedevent, and record both theorderIdand the event'saccount_id. - Confirm
read.positions(owner)lists thatorderId, and thatderiveAccountId(owner)equals the event'saccount_id, which proves you can key an indexed service on the right ID when one exists. - Mint a range position on 2 admission-grid bounds. Confirm a second
OrderMintedevent whoselowerTickandhigherTickare both finite. - Quote and close part of the directional position with more than 2 seconds left before expiry. Confirm a
LiveOrderRedeemedevent and recordreplacementOrderId. - After an expiry passes, build the
expiry_market::try_settlemoveCalldescribed in Settlement, send it, and confirm aMarketSettledevent. Claim withtx.claimSettledand confirm aSettledOrderRedeemedevent. - Queue a supply of at least 10 USDC. Confirm a
SupplyRequestedevent, then cancel it with the returned index and confirm aRequestCancelledevent. - Queue a second supply of at least 10 USDC with no floor and leave it queued. Wait for a flush, then confirm a
SupplyFilledevent carrying your queue index and a nonzeroread.plpBalance(owner). You cannot start a flush yourself, so this step waits on the operator's cadence. - Queue a withdrawal of at least 1 PLP against that balance. Confirm a
WithdrawRequestedevent, then wait for the next flush and confirm either aWithdrawFilledevent or aRequestCancelledrefund.