Skip to main content

DeepBook Predict Testnet Workflow

This page walks through a complete DeepBook Predict flow on Testnet with the TypeScript SDK: pricing a market board, creating an account, queuing directional and two-sided range mints, selling or settling the positions, and running the queued liquidity provider flow. It continues from the DeepBook Predict quickstart, which covers install, funding, and a first mint.

The walkthrough sends every transaction and every read through the /predict subpath of @mysten/deepbook-v3, version 2.7.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 every trade is a queued order, because Testnet runs Predict protocol version 4. Mainnet runs protocol version 3 until its own cutover, where trades fill immediately and the quote coin is native USDC, so the trade sections below apply to Mainnet only once it cuts over. 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.

caution

A chain read on 2026-10-13 found Testnet trading, with a new 1m and 5m market every window, and Mainnet's trading_paused flag set since 2026-09-30, so the walkthrough's trades abort on Mainnet for now. Read read.markets(), read.pool(), the ProtocolConfig object, and read.executionMode() 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.

  • Node.js 22 or later, with @mysten/deepbook-v3 2.7.0 or later and its @mysten/sui 2.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. DeepBook Predict SDK documents every function in these 3 groups.

info

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​

The SDK raises 2 typed errors: PredictInputError when it rejects an argument before building anything, and PredictMoveError when a simulation hits a Move abort. Errors in the DeepBook Predict SDK section covers both, how to map an abort to its module's error table, and why a quote doubles as a preflight.

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 Block Scholes input. Testnet requires the Block Scholes spot and forward within 2 seconds and the SVI surface within 10 seconds, and Mainnet allows the SVI surface 60 seconds. Read the live values from the ProtocolConfig shared object rather than assuming them, because the deployment manifest records only an initial snapshot.

A queued order uses the oracles twice. Placement snapshots the Block Scholes spot, forward, and SVI surface into the order. The spot it fills at comes later, from Pyth Lazer's signed update for the order's τ, which the keeper verifies and commits to the queue. The SDK resolves the oracle objects for placement, and you never handle a Lazer update unless you run a filler yourself.

Discover markets​

read.markets() returns every active market, which means live and not yet settled. Each entry describes one ExpiryMarket shared object:

FieldMeaning
idThe ExpiryMarket shared object ID
expiryMsExpiry as a Unix millisecond timestamp, typed bigint
tickSizeStrike grid granularity in USD
admissionTickSizeThe coarser grid a numeric strike must land on
mintPausedWhether minting is paused for this market
referencePriceThe 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. Predict Markets and Pricing SDK documents every market and pricing read.

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. The 1m cadence spaces expiries 1 minute apart, and a market-lifecycle call creates each market. 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 is 10 seconds on both deployments, and on Testnet the queue stops new orders whose τ would fall inside it, with EPastCutoff, so new orders stop about 11 seconds before expiry. On Mainnet a quote, mint, or live redeem inside the window aborts in protocol_config with code 7, 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 order fills and its limits refund it. Requiring a minimum time to expiry is what makes the plan-then-queue 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_cash is what moves that cash across. Anyone can call it. A mint that aborts on a market that looks otherwise healthy, with EInsufficientMarketCash on Testnet, is usually one nobody has funded yet. On Testnet each market also needs its MarketQueue, which the keeper creates right after the market, and the SDK's builders refuse an order with no-queue until it exists.

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 }, where strike is a USD number or the literal 'reference'.
  • Two-sided range: { underlying, expiryMs, side: 'range', lower, upper }, where both bounds are finite USD numbers and lower sits below upper.

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:

CallReturns
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.forwardThe forward price the surface anchors on
pricer.asOfSource 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. Predict Accounts SDK documents every account function.

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:

CallReturns
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 position held in the account as marketId and orderId pairs. A queued fill is not one of them; see Sell, refund, and settle
read.pool()Pool aggregates: plpTotalSupply, idleUsdc, and the 2 queue depths
read.executionMode()Which trade path the network is on: 'delayed' on Testnet, 'immediate' on Mainnet
read.queue(market, owner)A market's queue: the stuck gate, capacities, the order fee, a timing preview, and the largest mint spare cash admits now
read.planMint, read.planSellA queued order's quote, limits, debit, timing, and preflight
read.order(market, recordId), read.waitForOutcomeOne queue record and its display state
read.settlement(market)The settlement price, which still reads after expiry

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 predict-8-21 Testnet deployment​

As of 2026-09-14 no indexed read service exists for deepbook-predict-testnet or for Mainnet. The 3 public read-only JSON services that do exist index predict-8-21, an earlier 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.

ServiceBase URLPrimary data
Predicthttps://predict-server-v4.testnet.mystenlabs.comMarkets, market state, positions, vault state, protocol events, indexed configuration
Propbookhttps://propbook-server-v4.testnet.mystenlabs.comOracle bindings, Pyth observations, and Block Scholes spot, forward, and SVI observations
Accounthttps://account-server-v4.testnet.mystenlabs.comAccount 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. Set up the account explains the difference between the 2 IDs.

$ 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"
caution

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 NUMERIC values serialize as JSON strings. Parse monetary, quantity, probability, price, rate, and large identifier fields with decimal or bigint tooling rather than parseFloat.
  • Timestamps are milliseconds: Onchain and event timestamps use Unix milliseconds unless an endpoint documents seconds. Raw event pages window on from_ms, inclusive, and to_ms, exclusive, while named history endpoints use start_time and end_time in Unix seconds.
  • Order IDs are market-scoped: Packed order IDs are decimal u256 strings and are unique only inside one expiry market, so identify a position by expiry_market_id and order_id together.
  • Raw events page oldest-first: GET /events lists the allowlisted event resources and their filter profiles. Page one resource with GET /events/RESOURCE, then follow the opaque page.next_cursor with every filter unchanged until page.has_next_page is false. Unknown identifiers return null components or empty arrays rather than a 404. 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, and trading-loss-rebate-claimed. Requesting one returns 503 rather than an empty page, so treat the 4 event modules in Contract Information as the list of what the contracts actually emit.

How do I queue a directional mint?​

Testnet runs Predict protocol version 4, so a mint is a queued order. You place it with its limits and its money, it waits in the market's queue until Pyth Lazer publishes its signed price for the order's price instant, about 800 ms after placement, and a keeper then fills it at that price or refunds it. Queued orders covers the onchain side, and Predict Positions SDK documents every builder and read.

A directional position pays $1 per contract when settlement lands inside its range, and nothing otherwise, so quantity is the maximum payout in USD. The sample plans an all-in budget mint: read.planMint(owner, descriptor, { amount, slippageCents }) quotes the order at the current price and turns the slippage into the limits the order carries onchain, and tx.enqueuePlan builds the enqueue after a preflight of the queue gates, the balance, and the limits. A few checks run only onchain at placement, such as the market's SVI age and room in the payout tree for a new strike, so a plan that accepts can still abort there.

5 things decide whether the order lands and fills:

  • Pick a strike near the money. Admission refuses an entry probability outside the market's band, and each market snapshots its band at creation: 0.15 to 0.85 on Testnet markets created since 2026-10-13. A window's reference strike drifts away from the money as the price moves, so the sample takes the strike that prices near 50 cents with pricer.strikeAtProbability(0.5) and snaps it onto the admission grid with snapStrike. planMint throws entry-band for a strike outside the band.
  • Clear the minimum premium. Admission also requires a premium of at least 1 USDC, and the premium is the quantity multiplied by the entry probability. A budget plan whose budget buys less throws min-premium.
  • Slippage is cents per contract. It bounds how far the all-in price per $1 of payout may rise above the quote's price without the fee subsidy, so slippageCents: 10 lets a contract quoted at 42 cents before the subsidy fill at up to 52 cents, fees included. It is never a percentage. The limits leave the subsidy out because placement checks the order without it. A slippage of a few cents leaves the price little room between the quote and τ, so the order is often refused at placement with EOrderFailsLimits, or refunded at the fill, and a refund for the order's own limits keeps the order fee.
  • The order fee is per order. Each queued order escrows a flat fee, 0.02 USDC on Testnet, on top of its budget. plan.totalDebit is the budget plus that fee, and the plan's amount includes the fee by default. A fill, or a refund for the order's own limits or for failing admission, keeps the fee, and every other refund returns it.
  • New orders stop before expiry. An order whose τ would fall within 10 seconds of expiry aborts with EPastCutoff, so new orders stop about 11 seconds before expiry. tradeableMarket keeps a 30-second margin.

After the enqueue executes with events included, decode.enqueue(result) returns the record ID. Persist it: it is the handle for following the order, selling the position, and showing the result, and nothing onchain lists an account's queue records for you. read.waitForOutcome(descriptor, recordId) polls the record until it is filled or refunded. A filled order's view.position carries the packed order ID and quantity, and a refunded one's view.reason carries the refund reason.

Queue a two-sided range​

A two-sided range pays out when settlement lands in the half-open band (lower, upper]. Plan it with the same builders, passing the range arm of the market descriptor, { underlying, expiryMs, side: 'range', lower, upper }, to read.planMint and tx.enqueuePlan. Both bounds are finite USD numbers on the market's admission grid, snapped with snapStrike, and lower must sit below upper.

The chain treats each finite boundary of a two-sided range as its own leg. It charges a trading fee on each leg, so a range pays 2 boundary fees where a directional position pays 1, and it applies the entry-probability band to each leg as well as to the range. A wide range whose probability of settling above its lower bound exceeds the band's maximum is refused at placement, or refunded with reason 2 at the fill, even when its range probability sits inside the band. pricer.range(lower, upper) returns only the range probability, which sets the premium, so take the fee from the plan's quote. Design works through the fee on an example range.

How do I sell, refund, and settle a position?​

A filled mint does not enter your account. It stays in the market's queue as an Open record that your account owns, so read.positions(owner) does not list it: follow it with read.order(market, recordId). The position sits in the market's payout tree like any other, so it is backed and settles the same way. The following sample sells an Open record early, offers a manual refund of an overdue order, and reads a settled result.

Sell before expiry​

read.planSell(owner, market, { recordId, quantity, slippageCents }) quotes the sell and returns its proceeds, its net after the order fee, and the floors the order carries. The sell is a queued order too: it fills at the price for its own τ, within minProbability and minProceeds, or it is refunded and the position returns to an Open record. The whole position moves into the sell's own record, so after a sell, including a partial one, the remainder lives under the sell's record ID. Store that ID. A sell pays the order fee whatever it returns, so selling a position worth less than the fee nets a loss. When the sell could need more cash than the market holds spare, the builder appends plp::rebalance_expiry_cash so the market is funded before the fill.

Refunds​

There is no cancel. Keepers refund an order that is still unfinished 5 seconds after its τ, with reason 5 and the order fee returned. tx.refund(market) sends the same permissionless refund yourself, and the sample offers it only once view.canRequestRefund reports the order is overdue. A refund of USDC that your account's receive address cannot take, because it is on USDC's deny list or USDC is paused, stays parked in the record until tx.claimParked sends it, which matters on Mainnet's regulated USDC rather than on Testnet's test coin.

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. It looks for an exact price at the market's expiry timestamp in a fixed order:

  1. Exact Pyth spot: try_settle first looks for a Pyth observation at exactly the expiry timestamp.
  2. 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. Settlement emits MarketSettled carrying the settlement_price and a settlement_source of 0 for Pyth or 1 for Block Scholes.

Once the market settles, the queue's settlement walk, queue::settle_step, pays each Open record its settled payout into the owning account, the full quantity for a winner and nothing for a loser, and emits OpenRecordSettled. There is no claim to make. On Testnet a keeper settles, pays, and deletes the records within seconds of expiry, so by the time an app looks, read.order returns null for a filled record and the market's quotes abort with ELivePricingExpired. Keep each filled record's position.orderId, and compute what it is owed with read.settlement(market) and cost.settledPayout, as the sample's settledPayout does. The OpenRecordSettled event is the payment itself. A record the walk could not pay stays Open with OpenRecordPayoutSkipped, and tx.payOpen(market, recordId) pays it later.

Positions minted before the cutover​

A position an immediate mint recorded in the account before the cutover stays there. tx.claimSettled(owner, { underlying, expiryMs }, { orderId }) closes it in full after settlement, through the owner-authorized redeem_settled, and decode.claim(result) returns the payout. expiry_market::redeem_settled_permissionless closes such a position on the owner's behalf without the owner's signature, and the payout still lands in the owner's account, but protocol version 4 limits that function to an admin allowlist of keepers, so on Testnet an unlisted caller aborts with ENotSettledRedeemKeeper. On Mainnet, at protocol version 3, any address can still call it.

The immediate path on Mainnet​

Mainnet runs protocol version 3 until its own cutover, and its trades fill immediately: tx.mint, tx.mintAmount, and tx.mintCost open a position in the account inside the trader's transaction, and tx.redeem closes one live. Version 2.7.0 of the SDK deprecates those builders, because protocol version 4 always aborts them, and client.predict.read.executionMode() reports which path a network is on. Mint a position immediately and Predict Positions SDK cover that path, including its slippage caps, which default to uncapped.

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. Predict Liquidity SDK documents every liquidity function.

info

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 drains the queues at the frozen mark. On Testnet only an allowlisted flush operator can call finish_flush, 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.

caution

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:

FieldMeaning
plpTotalSupplyTotal PLP in issue, as a raw bigint
idleUsdcThe pool's idle quote balance as a decimal number, excluding cash funded into expiries
supplyRequestsPendingThe number of queued supply requests, not an amount
withdrawRequestsPendingThe 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.

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.

How do I verify the flow 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':

  1. Fund a Testnet address with SUI, then confirm the balance with sui client gas.
  2. Request test USDC, then confirm the balance with sui client balance, where it appears as DUSDC.
  3. Confirm the configuration block resolves: getDeployment('testnet').deployment is deepbook-predict-testnet on chain 4c78adac, and getConfig('testnet').quoteCoinType names the usdc::USDC type under package 0xc028557a….
  4. Call read.markets(). Confirm at least one market returns with mintPaused false, a non-null referencePrice, and at least 30 seconds left before its expiry.
  5. Create the account, then confirm client.predict.wrapperIdFor(owner) resolves with sui client object WRAPPER_ID.
  6. Deposit test USDC, then confirm read.balance(owner) reports the deposit.
  7. Confirm read.executionMode() returns 'delayed', then call read.planMint for a directional mint of a few USDC on a strike near the money, and confirm plan.accepting is true. Queue it with tx.enqueuePlan, confirm an OrderEnqueued event, and record the record ID from decode.enqueue(result) and the event's account_id.
  8. Confirm read.waitForOutcome ends 'filled' within a few seconds, with a QueuedOrderFilled event and an OrderMinted event in the keeper's transaction, and record view.position.orderId. Confirm that deriveAccountId(owner) equals the event's account_id, which proves you can key an indexed service on the right ID when one exists, and that read.positions(owner) does not list the queued fill.
  9. Queue a range mint on 2 admission-grid bounds near the money. Confirm a second fill whose OrderMinted carries 2 finite ticks.
  10. With more than 15 seconds left before expiry, plan a sell of part of the directional record with read.planSell, queue it, and confirm a QueuedOrderFilled and a LiveOrderRedeemed event. Record the sell's record ID, which now holds the remainder.
  11. After expiry, wait for the keeper's MarketSettled, OpenRecordSettled, and MarketPayoutsCompleted events, then confirm read.settlement(market) reports a price and cost.settledPayout with the stored order ID matches the payout on OpenRecordSettled.
  12. Queue a supply of at least 10 USDC. Confirm a SupplyRequested event, then cancel it with the returned index and confirm a RequestCancelled event.
  13. Queue a second supply of at least 10 USDC with no floor and leave it queued. Wait for a flush, then confirm a SupplyFilled event carrying your queue index and a nonzero read.plpBalance(owner). You cannot start a flush yourself, so this step waits on the operator's cadence.
  14. Queue a withdrawal of at least 1 PLP against that balance. Confirm a WithdrawRequested event, then wait for the next flush and confirm either a WithdrawFilled event or a RequestCancelled refund.