Skip to main content

Vault

The Predict pool is the counterparty to every trade. Liquidity providers deposit USDC, receive PLP shares, and collectively back the payout liability of every active expiry. All of that capital sits in one shared object, deepbook_predict::plp::PoolVault.

Liquidity is asynchronous. Nothing mints or burns PLP at the moment a provider asks for it. A request joins a queue with a price floor, and a later flush freezes the whole pool at one instant, values it, and drains both queues at that single frozen mark.

info

Collateral is the Move type usdc::usdc::USDC. On Mainnet that resolves to native USDC. On Testnet it is a mintable test coin at the same module path that displays as DUSDC and has no market value. Read the exact coin type from getConfig(network).quoteCoinType rather than assuming a symbol. The Move snippets on this page pin the Mainnet source commit, which serves both deployments; see Contract Information for the package and object IDs of each network.

The pool vault

The package creates and shares PoolVault at publish time, alongside the PLP currency registration. It holds 6 things:

FieldTypeHolds
idUIDThe shared object identity.
protocol_reserve_balanceBalance<USDC>Protocol-owned USDC, excluded from PLP redemption. No package call withdraws it.
fee_incentive_reserveBalance<USDC>Sponsor-funded USDC for taker fee subsidies, excluded from PLP net asset value (NAV).
lpLpBook<PLP>The PLP treasury cap, both request queues with their escrow, and the locked bootstrap balance.
expiry_accountingLedgerIdle USDC custody, the active-expiry set, per-expiry cash-flow rows, and the profit basis.
valuationOption<PoolValuation>The in-flight flush, if one exists. It is some exactly while a flush holds the valuation flag on ProtocolConfig.

The pool owns no expiry-local state. Each ExpiryMarket owns its own trading cash, strike exposure, payout backing, and risk state. The pool coordinates capital across expiries and delegates every expiry-local invariant to the expiry itself.

PoolVault fields

The struct declares those fields in this order:

The LP book

lp_book owns share issuance and both request queues. Every function in the module is public(package), so no integrator calls it directly. Observe its state through the PoolVault accessors and the pool events instead.

A queue is a paged list. Each RequestPage holds up to 64 entries, and each RequestEntry records the queue index, the requesting account, the recipient address, the escrowed amount, the request's own minimum output, and how many flushes it has already missed.

Click to open
Source for the LP book structs

The accounting ledger

pool_accounting holds idle USDC and the per-expiry books. It is also entirely public(package). The Ledger custodies the idle balance, tracks which expiries are active, and records for each registered expiry its allocation cap, initial cash target, cumulative cash sent and received, and fee-incentive allocation.

Click to open
Source for the ledger structs

Reading pool state

PoolVault exposes 11 permissionless read accessors plus its ID. Copy these signatures as written when you generate calls or bindings:

public fun id(vault: &PoolVault): ID
public fun idle_balance(vault: &PoolVault): u64
public fun protocol_reserve_balance(vault: &PoolVault): u64
public fun fee_incentive_reserve(vault: &PoolVault): u64
public fun plp_total_supply(vault: &PoolVault): u64
public fun supply_requests_pending(vault: &PoolVault): u64
public fun withdraw_requests_pending(vault: &PoolVault): u64
public fun active_expiry_markets(vault: &PoolVault): vector<ID>
public fun active_live_expiry_count(vault: &PoolVault, clock: &Clock): u64
public fun profit_basis_debits(vault: &PoolVault): u64
public fun profit_basis_credits(vault: &PoolVault): u64
public fun pending_protocol_profit(vault: &PoolVault): u64

Behavior worth knowing before you call them:

  • supply_requests_pending and withdraw_requests_pending return queue lengths, not escrowed amounts.
  • active_expiry_markets returns every registered active expiry, including settled ones that no sweep has removed yet. Use it to build the snapshot_expiry_pricer and value_expiry steps of a flush.
  • active_live_expiry_count counts only expiries still ahead of the clock, which is the count the live-market ceiling applies to.
  • plp_total_supply includes the permanently locked bootstrap shares, which are never withdrawable.

Whether a flush is in flight is a ProtocolConfig read, protocol_config::valuation_in_progress(config): bool, and whether that flush has snapshotted one market that still awaits its value_expiry is expiry_market::is_pending_valuation(market, config): bool. Both are observability reads. Neither gates trading.

Click to open
Source for the read accessors

Asynchronous liquidity

Liquidity moves in and out through 4 calls, and all 4 need nothing but account Auth. None of them is capability-gated, and none of them touches an oracle:

CallEscrowsLimit argumentReturns
request_supplyamount USDC pulled from account custodymin_plp_outu64 queue index
request_withdrawamount PLP shares pulled from account custodymin_usdc_outu64 queue index
cancel_supply_requestRefunds the escrowed USDC to the accountTakes the queue indexNothing
cancel_withdraw_requestRefunds the escrowed PLP to the accountTakes the queue indexNothing
public fun request_supply(
vault: &mut PoolVault,
wrapper: &mut AccountWrapper,
auth: Auth,
config: &ProtocolConfig,
amount: u64,
min_plp_out: u64,
root: &AccumulatorRoot,
clock: &Clock,
ctx: &mut TxContext,
): u64

public fun request_withdraw(
vault: &mut PoolVault,
wrapper: &mut AccountWrapper,
auth: Auth,
config: &ProtocolConfig,
amount: u64,
min_usdc_out: u64,
root: &AccumulatorRoot,
clock: &Clock,
ctx: &mut TxContext,
): u64

Both requests route through the account rather than the transaction signer, so a composing vault's own account receives the fill. The recipient recorded at request time is the account's receive_address, and the balance accumulator delivers fills and refunds to it.

The flush measures both limits after the pool's supply or withdraw fee, so they bound what the account actually receives. Each is a price floor rather than a promise of a quantity: if the flush has room for only part of the request, the fill is proportionally smaller at the same price and the remainder stays queued with its limit rescaled. At the deployed lp_request_limit_flush_attempts of 1, the first flush whose mark quotes below the limit cancels and refunds the request immediately rather than holding the queue head. An admin can raise the attempt count to at most 3, in which case a request rests through that many misses before the flush refunds it, and while it rests at the head of its queue that queue stops draining for the flush, so requests behind it wait too.

A flush does not block requests. A request submitted after a flush has taken its snapshot lands at or above that flush's queue cutoff, so it waits for the next flush, and no flush can fill it at a mark it already knows. Both cancels check the recipient: the caller's account receive_address must equal the recipient recorded on the request, otherwise the call aborts with lp_book::ENotRequestOwner. A cancel only works while the request is still pending, and neither cancel can run while a flush is in flight, so a request that is inside the current flush's cutoff stays committed to that flush's mark.

Minimum sizes apply per request:

ActionMinimumRawAbort
Supply10 USDC10_000_000lp_book::EBelowMinSupplyRequest
Withdraw1 PLP1_000_000lp_book::EBelowMinWithdrawRequest
Click to open
Source for the queue calls

The flush

A flush freezes the entire pool at one instant, values it against that frozen state, and then drains both queues at the resulting mark. It runs in 3 stages, and only the first stage has to be a single transaction:

StageCallsTransactionsWho can call
Snapshotstart_pool_valuation, then snapshot_expiry_pricer once per active market, then seal_valuation_snapshotExactly oneAn allowlisted PoolValuationCap holder, through a PoolValuationProof
Valuationvalue_expiry once per snapshotted live marketOne market per transaction, any number of transactionsAnyone
Finishfinish_flushOneAnyone

Snapshot

The snapshot stage is atomic because it uses 2 hot potatoes. registry::generate_pool_valuation_proof checks that a PoolValuationCap is still allowlisted and returns a PoolValuationProof that has no abilities. start_pool_valuation consumes it and returns a SnapshotStage, which also has no abilities. Every snapshot_expiry_pricer borrows the stage, and seal_valuation_snapshot consumes it. No transaction can store, transfer, or drop either potato, so the transaction that starts a flush is the transaction that snapshots and seals it, and only the starter's programmable transaction block can add a market to the snapshot:

public fun start_pool_valuation(
config: &mut ProtocolConfig,
vault: &mut PoolVault,
valuation_proof: PoolValuationProof,
supply_budget: Option<u64>,
withdraw_budget: Option<u64>,
clock: &Clock,
): SnapshotStage

public fun snapshot_expiry_pricer(
vault: &mut PoolVault,
_stage: &SnapshotStage,
market: &mut ExpiryMarket,
config: &ProtocolConfig,
propbook_registry: &OracleRegistry,
pyth: &PythFeed,
bs_values: &BlockScholesValueStore,
bs_svi: &BlockScholesSVIStore,
clock: &Clock,
ctx: &TxContext,
)

public fun seal_valuation_snapshot(
vault: &mut PoolVault,
stage: SnapshotStage,
config: &mut ProtocolConfig,
)

start_pool_valuation engages the valuation flag on ProtocolConfig, increments the flush sequence number, opens the snapshot stage, records the active expiry set as the markets this flush must value, stores the 2 drain budgets, records each liquidity provider (LP) queue's next index as that queue's cutoff, and stamps the start time. If a previous flush is still in flight, the call discards it first and emits FlushRestarted. The call aborts with ENotBootstrapped unless lock_capital has already bootstrapped the pool.

snapshot_expiry_pricer runs once per market in the active set. For a live market it loads a live pricer, converts it to a storable FrozenPricer that only this package can thaw, stores it in the valuation keyed by market ID, and stamps the market with its current cash and inventory-impact reserve. For a market that is already settled it runs the terminal sweep immediately and records no pricer, so that market contributes 0 and its recoverable cash is already in idle when the seal reads it. A market that has expired but not settled aborts with EExpiredMarketNotSettled, which reverts the whole snapshot transaction, so compose expiry_market::try_settle ahead of the snapshot for any market whose settlement might be due. The call skips a market outside the active set rather than aborting, so a stale offchain market list cannot fail the flush, and a second snapshot of the same market aborts with EExpiryPricerAlreadySnapshotted. The pricer load refuses an oracle observation written earlier in the same transaction, so a keeper cannot refresh the oracles and snapshot in one block.

seal_valuation_snapshot proves that every expected market has a frozen entry, aborting with EIncompleteValuationSnapshot otherwise, then freezes the vault-side figures the mark needs: idle balance, both profit-basis totals, and the pending protocol profit. It closes the snapshot stage, which is the moment trading reopens for any other transaction.

Valuation

value_expiry folds one market's snapshot-instant NAV into the running total. It takes no oracle objects and no clock, because the snapshot froze everything it reads:

public fun value_expiry(
vault: &mut PoolVault,
market: &mut ExpiryMarket,
config: &ProtocolConfig,
)

It is permissionless and idempotent. A market that is not in the expected set, or that a prior call has already valued, returns without changing anything, so a stranger valuing a market ahead of the keeper can only help. It aborts with EValuationSnapshotNotSealed before the seal. For a market frozen with a pricer it thaws the pricer, walks the payout tree's frozen shadow, subtracts that liability from the stamped free cash, adds the result to total_nav, and clears the market's stamp. A market frozen as settled contributes 0. The call moves no cash.

One market per transaction is the intended cadence. Only value_expiry walks a payout tree, and a tree holds one dynamic-field child per distinct strike tick, so splitting the walk across transactions is what keeps a large book inside Sui's per-transaction object budget.

Finish

finish_flush prices the pool and drains the queues:

public fun finish_flush(
vault: &mut PoolVault,
config: &mut ProtocolConfig,
clock: &Clock,
ctx: &mut TxContext,
): u64

It is permissionless, because start_pool_valuation committed the drain budgets at start, so a stranger who finishes a flush can only fill requests at the starter's budgets and never starve them. It aborts with EMissingExpiryValuation unless value_expiry has valued every expected market, computes the LP-attributable pool NAV from the frozen figures, drains both queues at that mark up to the recorded cutoffs and budgets, releases the valuation flag, emits FlushExecuted, and returns the pool NAV as a u64.

supply_budget and withdraw_budget, each an Option<u64>, bound how many requests that queue can process. none means unbounded. Neither budget depends on the other, so a supply backlog can never starve withdrawals.

The valuation window

max_valuation_window_ms on ProtocolConfig, deployed at 300000, which is 5 minutes, bounds finishing. Once now >= started_at_ms + max_valuation_window_ms, finish_flush aborts with EValuationWindowExpired, so no flush ever fills a request at a mark older than the window. Starting carries no deadline. There is no separate abort or discard call: the operator recovers by calling start_pool_valuation again, which discards the in-flight valuation, emits FlushRestarted with how many markets the flush expected and how many it valued, and takes a fresh snapshot. Any stamp the discarded flush left on a market goes stale by sequence number, and that market's next mint, redeem, or settlement clears it lazily.

What a flush blocks

The valuation flag no longer gates trading. Trades on a stamped market cannot reach the frozen figures, because the stamp captured the cash rows and the payout tree captures each node's shadow before its first post-snapshot mutation. What a flush blocks and what it leaves open:

FlowDuring the snapshot transactionDuring valuation and finish
Live quotes, mints, redeem_live, redeem_settledBlocked with ESnapshotInProgress, which only the starter's own transaction can observeOpen
try_settleOpenOpen
request_supply, request_withdrawOpenOpen, but a request lands beyond this flush's cutoff
cancel_supply_request, cancel_withdraw_requestBlocked with EValuationInProgressBlocked with EValuationInProgress
sponsor_fee_incentivesBlocked with EValuationInProgressBlocked with EValuationInProgress
rebalance_expiry_cashBlocked with ESnapshotStageOpenOpen
set_reference_tick, create_and_share_expiry_marketOpenOpen
Config setters the mark reads, such as fee rates, freshness windows, max_lp_pool_value, and max_valuation_window_msBlocked with EValuationInProgressBlocked with EValuationInProgress

A market created mid-flush is not in the frozen expected set and joins the next flush. A flush never blocks settlement, because the frozen mark is settlement-invariant: a stamped market that expires mid-window settles the instant it can, and the flush still folds its frozen pre-expiry mark.

Click to open
Source for the flush structs
Click to open
Source for the 5 flush calls

One mark for both sides

finish_flush computes the LP-attributable pool NAV purely from frozen figures, then snapshots it once against the PLP supply:

gross_pool_value = frozen_idle_balance + sum of each active expiry's snapshot_nav
exclusion = protocol_reserve_profit_share
* max(0, (frozen_profit_basis_credits + sum of snapshot_nav) - frozen_profit_basis_debits) / 1e9
pool_nav = max(0, gross_pool_value - exclusion - frozen_pending_protocol_profit)

Both subtracted terms are protocol profit that is not yet sitting in the reserve. exclusion is the protocol's share of profit that NAV has priced in but that has not yet materialized into cash. pending_protocol_profit is a cut that has materialized but whose cash the pool could not move yet because it had deployed idle elsewhere. The 2 are disjoint, and neither belongs to liquidity providers.

Each expiry's contribution is exact rather than estimated, and the flush measures it at the snapshot instant:

snapshot_nav = max(0, snapshot_free_cash - frozen_marked_liability)
snapshot_free_cash = snapshot_cash - snapshot_impact_reserve

frozen_marked_liability is the full payout-tree walk over the tree's frozen shadow, pricing each distinct boundary tick once through the thawed pricer, so every position open at the snapshot is worth exactly its quantity multiplied by its range probability. There is no verified-versus-unscanned bucket split, no uncertainty band, and no separate supply and withdraw pricing.

That single exact mark makes both directions fair:

  • A supplier cannot dilute incumbents, because the flush's mark never undercounts true recoverable value.
  • A withdrawer cannot overdraw, because the flush's mark never overcounts it.
  • No one can time the mark against a self-supplied oracle update, because only a pool-valuation-cap holder can start a flush, the pricer load refuses an observation written in the same transaction, and one transaction freezes every market.
  • No one can place a request against a mark it already knows, because the drain fills only requests indexed below the cutoff recorded at start.

The flush charges fees on the USDC leg after the mark and never inside it, so they do not move the price either side trades at.

Filling the queues

The drain runs supplies first, then withdrawals, each from the head of its queue in order and only up to that queue's cutoff. Because supplies run first, USDC supplied in a flush is available to pay that same flush's withdrawals.

Supply fills mint PLP against the frozen pair:

fee    = ceil(amount * plp_supply_fee_rate / 1e9)
shares = floor((amount - fee) * total_supply / pool_nav)

Withdraw fills burn PLP and pay out of idle USDC:

gross  = floor(shares * pool_nav / total_supply)
payout = gross - ceil(gross * plp_withdraw_fee_rate / 1e9)

The fill consumes the whole escrow in both directions. A supply fill joins the request's full USDC into idle, fee included, and a withdraw fill burns the request's full escrowed PLP and leaves the fee in idle.

Beyond the operator budgets, 3 things bound a pass:

  • Pool value cap: Supplies fill only up to max_lp_pool_value, which the flush measures against the frozen mark plus the supplies already filled in this flush. A head larger than the remaining headroom fills to the headroom and keeps its remainder queued. Both deployments set the cap to 500_000_000_000, which is 500,000 USDC. It is mutable protocol state, so read it before you size a supply.
  • Idle liquidity: Withdrawals fill only up to live idle USDC at finish time. A head that idle cannot cover in full keeps its position and stops the pass, and the flush pays it as far as idle reaches. The flush never reorders withdrawals around a too-large head.
  • Request limits: The flush refunds a head whose quote is below its own min_plp_out or min_usdc_out at the deployed attempt count of 1, and the pass continues. The flush refunds a head whose mark or quote is not executable at all rather than aborting.

Cash already funded into an expiry is not directly redeemable until it returns through a rebalance or settlement, so idle can bound and defer a large exit. It cannot force-drain a live market.

Worked example

Assume these frozen values when finish_flush runs, with plp_supply_fee_rate at 0 and plp_withdraw_fee_rate at 2_000_000, which is 0.2 percent:

InputRaw valueReads as
frozen_idle_balance300_000_000_000300,000 USDC
Sum of snapshot_nav120_000_000_000120,000 USDC
frozen_profit_basis_credits10_000_000_00010,000 USDC
frozen_profit_basis_debits40_000_000_00040,000 USDC
frozen_pending_protocol_profit00 USDC
plp_total_supply400_000_000_000400,000 PLP

The mark follows directly:

  • Gross pool value: 300,000 + 120,000 = 420,000 USDC.
  • Exclusion: 10% of max(0, 10,000 + 120,000 - 40,000) = 9,000 USDC.
  • Pool NAV: 420,000 - 9,000 - 0 = 411,000 USDC.
  • Mark: 411,000 / 400,000 = 1.0275 USDC per PLP.

A queued 1,000 USDC supply and a queued 10,000 PLP withdrawal then fill at that one mark:

  • Supply 1,000 USDC: floor(1_000_000_000 * 400_000_000_000 / 411_000_000_000) = 973_236_009, which is 973.236009 PLP.
  • Withdraw 10,000 PLP, gross: floor(10_000_000_000 * 411_000_000_000 / 400_000_000_000) = 10_275_000_000, which is 10,275 USDC.
  • Withdrawal fee: ceil(10_275_000_000 * 2_000_000 / 1_000_000_000) = 20_550_000, which is 20.55 USDC.
  • Withdraw payout: 10_275_000_000 - 20_550_000 = 10_254_450_000, which is 10,254.45 USDC.

The pool NAV sits under the deployed 500,000 USDC max_lp_pool_value, so the supply has headroom to fill in full.

Funding a market

A freshly created ExpiryMarket holds zero USDC and is not mintable. Minting asserts backing but never pulls pool cash, so the call that makes a market tradable is:

public fun rebalance_expiry_cash(
vault: &mut PoolVault,
market: &mut ExpiryMarket,
config: &ProtocolConfig,
clock: &Clock,
)

rebalance_expiry_cash is permissionless and standalone. Anyone can call it at any cadence, including while a flush is in flight, because the snapshot stage froze every figure the mark reads. Its one refusal is the open snapshot stage, where it aborts with ESnapshotStageOpen. It handles all 3 per-market cases: initial funding of an unfunded market, ongoing top-up or surplus sweep of a live market, and the terminal sweep of a settled one. The snapshot stage runs the same terminal sweep for a market that is already settled when the flush starts.

The policy is a band around each expiry's required cash, with hysteresis so that small moves do not thrash cash back and forth:

required_cash    = payout_liability + inventory_impact_reserve
target_cash = max(required_cash * (1 + band), initial_expiry_cash)
sweep_threshold = max(required_cash * (1 + 2 * band), initial_expiry_cash)

band is expiry_rebalance_pct, which is 100_000_000 at 1e9 scale, or 10 percent. Below target_cash the pool tops the market up, and above sweep_threshold it pulls the excess back to idle.

Both floors are the market's own initial_expiry_cash, snapshotted from its cadence at market creation, not one protocol-wide constant. Both deployments enable the same 2 cadences with the same terms:

Cadenceinitial_expiry_cash rawReads asmax_expiry_allocation rawReads as
1m2_000_000_0002,000 USDC10_000_000_00010,000 USDC
5m2_000_000_0002,000 USDC10_000_000_00010,000 USDC

Both deployments configure the 1h, 1d, 1w, and 1mo cadences as disabled, with every term at zero, so no market exists on them.

constants::expiry_cash_floor!(), which is 1_000_000_000, plays no part in the rebalance. It is the minimum admissible initial_expiry_cash that cadence validation enforces, so a cadence configured below 1,000 USDC aborts with EInvalidCadenceConfig, as does one whose initial_expiry_cash exceeds its max_expiry_allocation. A market on a cadence configured above that minimum floors its rebalance at the configured figure. Read the live figures with registry::cadence_config, and see the cadence table on Contract Information.

A top-up has 2 limits: available idle USDC, and the expiry's remaining funding room. Returns replenish funding room, and the cap is the max_expiry_allocation snapshotted from the cadence configuration at market creation, 10,000 USDC on both enabled cadences. Exceeding it aborts with pool_accounting::EMaxExpiryFundingExceeded.

Call expiry_market::try_settle first in the same transaction when settlement might be due. An expired unsettled market is a no-op for the rebalance.

Click to open
Source for rebalance_expiry_cash

Per-expiry backing

The package enforces solvency per expiry, not pool-wide. Each market's cash leaf, expiry_cash::ExpiryCash, asserts on every cash movement that:

cash_balance >= payout_liability + inventory_impact_reserve

For a live market, payout_liability is a settlement floor plus a liquidity buffer:

payout_liability = max_net_payout
+ backing_buffer_lambda * (sum of net_payout - max_net_payout) / 1e9

max_net_payout is the largest summed net payout at any single settlement price. Exactly one price settles a market, so that floor alone covers every possible settlement outcome in full. The buffer adds backing_buffer_lambda, which the deployed template sets to 310_000_000 or 31 percent, of the gap between that floor and the sum of every open order's maximum payout. The buffer funds early exits of positions that do not overlap the book's worst-case price point. A live redeem that would push cash below the requirement aborts with expiry_cash::EInsufficientCash, and the holder can close a smaller quantity, retry after the next rebalance, and always receives full payment at settlement.

The inventory_impact_reserve is an isolated escrow of inventory-impact charges collected at mint, minus rebates paid to voluntary live closes. NAV and pool sweeps exclude it while the market is live, and settlement releases the residual because no live close can earn another rebate.

How the leaf treats each case:

  • Receiving cash: Joins the funds without re-checking backing, because receiving cash can only improve it.
  • Releasing surplus: Requires cash to cover required backing plus the released amount, so a sweep can never break solvency.
  • Settled release: Computes the terminal liability at the settlement price, asserts backing, and returns only the strict excess.

Read the live figures from the market:

public fun cash_balance(market: &ExpiryMarket): u64
public fun inventory_impact_reserve(market: &ExpiryMarket): u64
public fun payout_liability(market: &ExpiryMarket): u64
public fun required_cash(market: &ExpiryMarket): u64
public fun fee_incentive_balance(market: &ExpiryMarket): u64
Click to open
Source for the ExpiryCash custody leaf

expiry_cash declares no public functions. Every function is public(package), so integrators observe the leaf only through the ExpiryMarket getters above and through the pool events.

PLP shares

PLP is the Predict pool share token, registered as a 6-decimal currency to match USDC. Its type is plp::PLP under the Predict package, and its treasury cap lives inside LpBook, so a flush mints PLP only on a supply fill and burns it only on a withdraw fill.

Click to open
Source for the PLP witness

Fixed-point ratios throughout the pool use 1e9 scaling, while USDC and PLP amounts use their coins' 6 decimals. The 2 scales never mix in one field.

Bootstrap, sponsorship, and capacity

You have 3 operations outside the ordinary supply and withdraw path:

OperationCallAuthorization
Bootstrap the pool oncelock_capitalAdminCap, and only while total supply is zero
Sponsor taker fee incentivessponsor_fee_incentivesPermissionless, and blocked while a flush is in flight
Start a flushstart_pool_valuationAn allowlisted PoolValuationCap, through a PoolValuationProof from registry::generate_pool_valuation_proof

lock_capital permanently locks a minimum of 10 USDC, mints matching PLP one to one into the book's locked balance, and joins the USDC into idle. The caller receives no shares. This lock keeps total supply above zero for the vault's lifetime and assigns rounding dust to a holder that can never withdraw. Supply, withdraw, and flush flows all abort with ENotBootstrapped until it has run, and a second call aborts with EAlreadyBootstrapped.

sponsor_fee_incentives accepts USDC from anyone, with a minimum of 10 USDC. The payment joins the pool-level fee incentive reserve, which PLP NAV excludes and which the ordinary rebalance flow allocates out to expiry markets. Read the pool-level balance with fee_incentive_reserve and the per-market balance with expiry_market::fee_incentive_balance.

The vault bounds capacity when a market registers, not when it trades. Registering a market whose expiry is still ahead of the clock aborts with EMaxLiveExpiryMarketsExceeded once the vault already holds 24 registered live pre-expiry markets. That ceiling bounds how many snapshot_expiry_pricer commands one snapshot transaction can need. Settled or expired markets still sitting in the active set do not count against it, because the ceiling counts only expiries still ahead of the clock.

Click to open
Source for lock_capital and sponsor_fee_incentives

Pool events

vault_events declares 15 events, all with copy, drop, and store. The table lists fields in declaration order, which is also their Binary Canonical Serialization (BCS) order:

EventFields
ExpiryCashReceivedpool_vault_id, expiry_market_id, settlement_price, amount
ExpiryCashRebalancedpool_vault_id, expiry_market_id, amount, to_expiry, target_cash, protocol_profit_realized
ExpiryProfitMaterializedpool_vault_id, expiry_market_id, lp_profit, protocol_profit, protocol_reserve_balance_after, profit_basis_after, pending_protocol_profit_after
SupplyRequestedpool_vault_id, account_id, recipient, index, amount, min_plp_out, requests_pending_after
WithdrawRequestedpool_vault_id, account_id, recipient, index, amount, min_usdc_out, requests_pending_after
RequestCancelledpool_vault_id, account_id, recipient, index, amount, is_supply, reason, requests_pending_after
RequestLimitMissedpool_vault_id, account_id, recipient, index, amount, is_supply, quoted_output, min_output, missed_flushes, max_misses
SupplyFilledpool_vault_id, account_id, recipient, index, usdc_amount, shares_minted, fee_usdc, usdc_remaining, requests_pending_after
WithdrawFilledpool_vault_id, account_id, recipient, index, shares_burned, usdc_amount, fee_usdc, shares_remaining, requests_pending_after
FlushExecutedpool_vault_id, epoch, pool_value, total_supply, supply_fee_rate, withdraw_fee_rate, active_market_nav, market_count, idle_balance_before, frozen_idle_balance, supplies_filled, withdrawals_filled, requests_processed, idle_balance_after, total_supply_after, supply_request_cutoff, withdraw_request_cutoff, snapshot_timestamp_ms
FlushRestartedpool_vault_id, expected_market_count, valued_market_count
CapitalLockedpool_vault_id, amount
FeeIncentivesSponsoredpool_vault_id, sponsor, amount, reserve_after
FeeIncentivesAllocatedpool_vault_id, expiry_market_id, amount, pool_reserve_after, expiry_incentive_balance_after, expiry_incentives_allocated_after
FeeIncentivesReturnedpool_vault_id, expiry_market_id, amount, pool_reserve_after

RequestCancelled.reason distinguishes why an escrow came back:

ValueMeaning
0The account canceled the request.
1The flush found the mark or quote not executable.
2The request missed its own output limit.

One FlushExecuted per flush carries the priced mark and its breakdown, so pool_value divided by total_supply is the exact mark that flush's fills used, and frozen_idle_balance + active_market_nav reconstructs its gross. idle_balance_before is a live read taken immediately before the drain, for telemetry only; it can differ from frozen_idle_balance because rebalances, settlement sweeps, and trading run between the snapshot and the finish. snapshot_timestamp_ms is the instant the mark prices the pool at, and the 2 cutoffs are the queue indexes the drain stopped at. start_pool_valuation emits FlushRestarted when it discards an in-flight flush, and its counts distinguish an abandoned flush from one that never progressed.

Click to open
Source for FlushExecuted and FlushRestarted

Read and write the pool from a client

@mysten/deepbook-v3 version 2.3.0 exposes the LP path on its /predict subpath for both networks. Every read runs against the Sui client's core API, with no indexer or server in the path.

The 4 queue builders mirror the Move calls:

CallSignatureUnits
Queue a supplyclient.predict.tx.supplyPlp(owner, amountUsdc, { minPlpOut })USD decimal, number or string; minPlpOut is raw PLP shares as a bigint
Queue a withdrawalclient.predict.tx.withdrawPlp(owner, shares, { minUsdcOut })Raw PLP shares as a bigint, not USD; minUsdcOut is a USD decimal
Cancel a supplyclient.predict.tx.cancelSupplyPlp(owner, index)bigint queue index
Cancel a withdrawalclient.predict.tx.cancelWithdrawPlp(owner, index)bigint queue index

withdrawPlp taking raw shares is the largest risk on this surface. Read the balance to withdraw with client.predict.read.plpBalance(owner), which also returns a raw bigint, and pass it straight through.

The options object carries the request's price floor. minPlpOut is the fewest 6-decimal PLP shares you accept for the whole supply, and minUsdcOut is the least USDC, after the withdraw fee, you accept for the whole withdrawal. Both are optional and default to no floor. Because both deployments run lp_request_limit_flush_attempts at 1, the first flush whose mark misses a floor cancels and refunds that request, so set a floor at which you would rather take the refund than the fill.

Recover the queue index from the request transaction with the decoder, which parses event BCS locally and makes no network call:

const req = client.predict.decode.plpRequest(supplyResult);
req.kind; // 'supply' | 'withdraw'
req.index; // bigint - pass to cancelSupplyPlp or cancelWithdrawPlp

client.predict.read.pool() returns a PoolSummary. The last 2 fields count requests rather than amounts:

FieldTypeMeaning
plpTotalSupplybigintRaw PLP supply.
idleUsdcnumberIdle USDC as a USD decimal.
supplyRequestsPendingnumberNumber of queued supply requests, not their total amount.
withdrawRequestsPendingnumberNumber of queued withdraw requests, not their total amount.

The following example queues a supply request with a floor and reads back the receipt index. It selects its network from the examples' NETWORK constant:

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();
}

This one queues a withdrawal and then cancels it:

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);
}

Error codes

plp raises these:

CodeConstantCause
0EMissingExpiryValuationfinish_flush ran before value_expiry had valued every expected market.
1ENotBootstrappedA supply, withdraw, or flush start ran before lock_capital.
2EAlreadyBootstrappedA caller ran lock_capital a second time.
3EBelowMinBootstrapLiquidityThe lock_capital payment is below 10 USDC.
4EBelowMinFeeIncentiveSponsorshipThe sponsorship is below 10 USDC.
5EMaxLiveExpiryMarketsExceededRegistering the market would exceed 24 live pre-expiry markets.
6EValuationSnapshotNotSealedvalue_expiry ran before seal_valuation_snapshot.
7EExpiryPricerAlreadySnapshottedsnapshot_expiry_pricer ran twice for one market in one flush.
8EIncompleteValuationSnapshotseal_valuation_snapshot ran before snapshot_expiry_pricer had covered every expected market.
9EExpiredMarketNotSettledsnapshot_expiry_pricer met a market past its expiry that has not settled.
10EValuationWindowExpiredfinish_flush ran at or after started_at_ms + max_valuation_window_ms.
11ESnapshotStageOpenrebalance_expiry_cash ran between start_pool_valuation and seal_valuation_snapshot.

lp_book raises these through the queue calls:

CodeConstantCause
0ERequestNotFoundNo entry carries the index, the queue is empty, or a partial fill equals the full amount.
1EBelowMinSupplyRequestThe supply is below 10 USDC.
2EBelowMinWithdrawRequestThe withdrawal is below 1 PLP.
3ENotRequestOwnerThe canceling account is not the recorded recipient.

pool_accounting and expiry_cash raise these through cash movement:

CodeConstantModuleCause
0EUnknownRegisteredExpirypool_accountingThe expiry ID is not registered to this vault.
1ERegisteredExpiryAlreadyExistspool_accountingA caller registered the expiry twice.
2EMaxExpiryFundingExceededpool_accountingFunding would exceed the expiry's max_expiry_allocation.
3ETerminalAccountingStartedpool_accountingLive-path accounting ran after terminal accounting began.
0EInsufficientCashexpiry_cashThe backing check failed, or a release or payment exceeds the balance.
1EInventoryImpactRebateExceedsReserveexpiry_cashA rebate exceeds the isolated escrow.

A flush also runs into ProtocolConfig gates: EValuationNotInProgress (2) when a snapshot, valuation, or finish call runs with no flush in flight, EValuationInProgress (1) when a cancel, a sponsorship, or a setter the mark reads runs while one is, ESnapshotInProgress (6) when the starter's transaction also contains a trade or a second start_pool_valuation, and EPackageVersionDisabled (3) or EProtocolFrozen (5) when the package version or freeze switch blocks the call. registry::generate_pool_valuation_proof aborts with registry::EPoolValuationCapNotValid (3) for a capability that is not allowlisted.

Error numbers repeat across modules, so resolve an abort against the module named in the abort location rather than against the number alone. For market creation, cadence configuration, and the capabilities, see Registry. For the accounts these calls route through, see Accounts and Custody.