Skip to main content

Oracle

DeepBook Predict declares no oracle object of its own. Oracle data lives in the separate propbook package, which publishes an OracleRegistry plus one shared object per feed, and Predict reads those objects by reference at call time. Every priced call therefore takes concrete oracle objects as arguments, and Predict checks each one against the registry's current canonical binding before it prices anything.

The Mainnet and Testnet deployments run the same package sources, so everything on this page holds on both networks; only the object IDs differ. The IDs below come from each network's deployment manifest and match getConfig(network).underlyings.BTC in @mysten/deepbook-v3/predict. Resolve them from the registry at runtime rather than caching them, because rebinding an underlying changes which object is canonical.

ObjectMainnetTestnet
OracleRegistry0x1ff67a8499b0af7c0fbb26ba82c319be25413af93d9a961ee60f8d1229e7de7c0xda71af88a8b9d01b6913937a84c5a63bb4e1015a7a2142185ec9ef73f675615f
BTC PythFeed0x44bd2e00549973bb6a58785836e97ed80b4979e67dc3bf312c997e56053e53760x7179188b3a27758c53668a4ec09a81be114dea5ffabcf367f4e720a26db8ee59
BTC BlockScholesValueStore0xe7c83ee4ac0c300d68d8d4c3d265d78287b4767dcbc5f279cd6f210b6248d4930x77dee47fb870753c740439f436e0c30c00c60f13f171c901125b3d60f89518e5
BTC BlockScholesSVIStore0x8e5568aebf490c2adfd4e010a9b4ddcf7c774183f58c98fcd438bae1fc68f5610xe8aa80965d201e2cb18359c3aa315e3abc5bbf807a10a59d6e011ef41be5db7a

BTC is propbook_underlying_id 1 on both networks. The full package and object tables are on Contract Information.

Oracle objects

A Predict pricing call involves 4 shared objects:

ObjectModuleHolds
OracleRegistrypropbook::registryThe source catalog and the canonical binding per underlying, oracle kind, and value kind
PythFeedpropbook::pyth_feedRaw and normalized Pyth spot observations for one Pyth source
BlockScholesValueStorepropbook::block_scholes_storeBlock Scholes spot and per-expiry forward observations for one underlying
BlockScholesSVIStorepropbook::block_scholes_storeBlock Scholes stochastic volatility inspired (SVI) surface parameters per expiry for one underlying

There is one OracleRegistry per deployment, one PythFeed per Pyth source, and one value store and one SVI store per underlying. Every function here is a public fun invoked as a moveCall.

The oracle registry

OracleRegistry is the shared object that says which feed object is authoritative for which underlying:

A binding has 3 keys: the underlying ID, the oracle kind, and the value kind. Pyth is oracle kind 0 and spot is value kind 0. The registry tracks the Block Scholes stores separately, as a pair per underlying, because an admin always registers a value store and an SVI store together and they share one base asset string.

Discover the canonical objects

Resolve an underlying to its 3 feed objects before building a transaction:

Both lookups return an Option, which is empty when the underlying has no binding of that kind. BlockScholesStorePair comes back by value, so read the 2 IDs off it with the accessors above and read the vendor's base asset string with block_scholes_base_asset.

Richer metadata is available when you need the source ID behind a binding rather than just the object ID:

contains_pyth_source and propbook_pyth_id_for_source answer the same question from the source side, for callers that hold a Pyth source ID rather than a Predict underlying ID.

Create and bind feeds

Creating a Pyth feed object is permissionless, but binding one to an underlying is not:

RegistryAdminCap is a Propbook capability, separate from the Predict AdminCap. create_and_share_block_scholes_stores runs once per underlying and aborts with EBlockScholesStoresAlreadyExist on a second call. bind_pyth_to_underlying aborts with EBindingAlreadyExists if a binding is already present, so moving an underlying to a different feed goes through replace_pyth_binding_for_underlying instead, which aborts with EBindingNotFound when there is nothing to replace.

Binding changes emit OracleSourceRegistered, OracleBound, BlockScholesStoresRegistered, and OracleRebound. Those events carry copy, drop but no store, so they exist only in the transaction's event stream.

propbook::registry raises these errors:

CodeNameRaised when
0ESourceAlreadyExistsA caller registers the same source key twice
1ESourceNotFoundThe replacement feed is not in the source catalog
2EInvalidOracleObjectThe supplied object ID does not match the cataloged source object
3ESourceAlreadyBoundThe source key already belongs to a different underlying
4EBindingAlreadyExistsbind_pyth_to_underlying on an underlying that already has a binding
5EBindingNotFoundreplace_pyth_binding_for_underlying with no existing binding
6EBlockScholesStoresAlreadyExistA second store pair for one underlying
7EInvalidBlockScholesBaseAssetAn empty base asset, or one longer than 32 bytes

Pyth feed

PythFeed is a shared object holding one observation lane of raw Pyth spot values:

RawSpot keeps Pyth's own representation: a price magnitude and sign, an exponent magnitude and sign, and the feed's update timestamp in microseconds. Sign and magnitude are separate fields because Move has no signed integer type. The Testnet sources differ from the Mainnet pin only by a 2-line documentation comment and attribute at the top of this module, added so one Update type links against both deployed Pyth Lazer packages; no signature, struct, event, or error differs.

Read spot

The feed has 4 accessors that cover the latest observation and an exact historical one:

The raw reads abort with ERawSpotNotFound when no observation exists, while the normalized reads return an Option so a caller can distinguish absence from a value. Normalization applies the exponent and returns a fixed-point price on the standard 1e9 scale. The exact reads take a source timestamp in whole milliseconds and match it exactly, which is what makes reference ticks and settlement reproducible rather than dependent on when a transaction lands.

The raw_pyth_source_id, raw_price_magnitude, raw_price_is_negative, raw_exponent_magnitude, raw_exponent_is_negative, and raw_feed_update_timestamp_us accessors unpack a RawSpot.

Write observations

Writes are permissionless because possession of a verified Pyth Lazer update is the authority:

The 2 write paths are separate and never overlap. update replaces the latest observation, and only when the new source timestamp strictly advances: it ignores a zero, future, duplicate, or stale generation timestamp without an abort and without an event, so redelivering a carried-forward price cannot renew a consumer's freshness window. insert_at writes the exact-timestamp slot and leaves the latest observation untouched. The first valid observation owns an exact key, and no later write can replace it, so a backfill can never overwrite history. insert_at also aborts with EInsertTimestampNotExactMillisecond when the envelope timestamp is not a whole millisecond, and with ESettlementCarryExceedsWindow when Pyth generated the price more than 2,000 ms before that envelope, so a long-carried price cannot claim a settlement key.

migrate moves the object forward to the current package version and never backward.

propbook::pyth_feed raises these errors:

CodeNameRaised when
0EWrongVersionWriting to a feed whose version is not the current one
1ENotNewerVersionmigrate that would not advance the version
2ERawSpotNotFoundraw_spot or raw_spot_at with no observation
3ELazerFeedNotFoundThe update carries no matching Lazer feed
4ELazerValueUnavailableThe Lazer feed carries no usable price
5EInsertTimestampNotExactMillisecondThe insert_at envelope is not a whole millisecond
6EFeedTimestampAfterEnvelopeThe generation time is after the envelope time
7ESettlementCarryExceedsWindowCarry above the 2,000 ms maximum
8EUnderlyingAlreadyAssignedRebinding a feed to a different underlying

Block Scholes stores

The vendor surface lives in 2 shared objects. BlockScholesValueStore holds spot and per-expiry forward values, and BlockScholesSVIStore holds SVI parameters per expiry:

Both stores key their tables by a series identifier, a u256 derived from the base asset string and, for a forward or SVI series, the expiry.

Reads and series identifiers

Every observation comes back wrapped in a BsRead, and SVI values carry an SVIParams:

SVIParams uses the same magnitude-and-sign split as the Pyth raw spot, because a, rho, and m can be negative while b and sigma cannot. The 8 accessors named svi_a_magnitude through svi_m_is_negative unpack it.

Read the observations themselves:

Each returns an Option, empty when that series has no observation. spot_at matches an exact source timestamp, which is the read settlement falls back to.

Compute a series identifier when you need to address a series directly:

Unpack a BsRead with the read accessors:

The 2 timestamps answer 2 different questions. source_timestamp_ms is the provider's own time for that observation, the per-update value_timestamp of a spot or forward and the svi_timestamp of an SVI row, and onchain_timestamp_ms is the time the write landed on Sui. Latest ordering, exact spot history, the freshness windows, and the SVI roll-down all key on the source timestamp; the batch envelope time is transport metadata only and appears nowhere in a BsRead. Judge vendor freshness from the source timestamp and landing order from the onchain timestamp, and never sort by source timestamp alone, because an older source observation can land later. There is no model timestamp: BsRead carries exactly the 4 fields above, so an integrator decoding BlockScholesObservationRecorded payloads from the previous-generation predict-8-21 deployment must drop the leading model_timestamp_ms it used to read.

writer_digest records the transaction digest that wrote the observation. Predict reads it to enforce the same-transaction guard described later on this page.

Write batches

Writes are permissionless because the signed vendor batch is the authority:

The forward and SVI batches take a parallel expiries_ms vector and abort with EUnexpectedBatchLength when its length does not match the batch. A row whose derived series identifier does not match the row's own identifier aborts with ESeriesIdMismatch. insert_at fills unoccupied exact spot slots only, and migrate_value_store and migrate_svi_store move a store forward to the current package version. Applying a batch emits BlockScholesBatchIngested alongside per-observation events. The batch event's batch_timestamp_ms is the envelope time and proves only that the feed is running; it is never a freshness or roll-down clock, which is why an ingested batch that advances no series still emits it with applied at zero.

Store creation is internal to the package and reachable only through registry::create_and_share_block_scholes_stores.

Oracle reads

propbook::oracle_lane supplies the lane type that PythFeed embeds and the read wrapper it returns:

OracleRead is the Pyth-side counterpart of BsRead, with the same 4 fields and the same 4 accessors:

The lane declares no error constants. Its write paths silently ignore an invalid, stale, or already-occupied observation rather than aborting, so a keeper that submits a late batch loses the write instead of losing the transaction.

How Predict consumes oracles

Predict validates whatever objects you pass, so a call fails rather than pricing against the wrong feed. Different calls need different objects:

CallOracleRegistryPythFeedBlockScholesValueStoreBlockScholesSVIStore
expiry_market::load_live_pricerYesYesYesYes
plp::snapshot_expiry_pricerYesYesYesYes
plp::value_expiryNoNoNoNo
expiry_market::try_settleYesYesYesNo
expiry_market::set_reference_tickYesYesNoNo
registry::create_and_share_expiry_marketYesNoNoNo

Settlement needs no volatility surface, so try_settle takes no SVI store. A reference tick is a single spot reading, so set_reference_tick takes only the Pyth feed. Market creation reads no price at all: it takes the registry solely to confirm the underlying has canonical bindings.

The pool flush reads oracles in exactly one place. plp::snapshot_expiry_pricer takes all 4 objects, loads a live pricer for one market, and freezes it into the vault's valuation snapshot; the later plp::value_expiry takes only the vault, the market, and the configuration, and prices the market from that frozen copy, so it can run in a different transaction from the snapshot without touching an oracle. See Vault for the staged flush.

The pricer

Live pricing runs through a Pricer, which you build once per transaction from all 4 objects:

Pricer has copy and drop but no store. You cannot put it in an object, a dynamic field, or anything that outlives the transaction, so every transaction that quotes, mints, or redeems must call load_live_pricer again. Build it once per programmable transaction block and pass the same value into every quote and trade command in that block.

The pool flush is the one path that carries a mark across transactions, and it uses a second type for it. snapshot_expiry_pricer loads a Pricer and converts it into a FrozenPricer, which has store and lives inside the vault's PoolValuation until value_expiry prices the market from it. FrozenPricer is a deliberately stale mark: its constructor into_frozen and its consumer thaw are package-internal, and no quote, mint, or redeem accepts one, so a persisted mark can never price a trade. PricingSVI gained store for the same reason and remains without public constructors or accessors.

A pricer binds to the market you loaded it for. Passing it to a different market aborts with expiry_market::EWrongPricer. Loading one at or after the market's expiry aborts with pricing::ELivePricingExpired.

A pricer feeds 2 read functions:

Both return a 1e9-scaled probability and take range_codec::Strike values, which you build from a tick with range_codec::strike_from_tick. The SVI shapes behind them, pricing::RawSVI and pricing::PricingSVI, have no public constructors or accessors: the pricer is the only public view of the surface. The pricer also records the 4 source timestamps it validated, and the trade events report the same 4 values, so an event's block_scholes_*_source_timestamp_ms fields are the provider per-update times the pricer keyed freshness and roll-down on.

Binding validation

Loading a pricer first checks all 3 feed objects against the registry's current bindings, then checks that the market has not expired. A mismatch aborts before Predict reads any price:

CodeNameRaised when
7EWrongPythFeedThe supplied PythFeed is not the canonical binding for the underlying
8EWrongBlockScholesValueStoreThe supplied BlockScholesValueStore is not the canonical binding
11EWrongBlockScholesSVIStoreThe supplied BlockScholesSVIStore is not the canonical binding

try_settle and set_reference_tick run the same check over the subset of objects they take, so a rebinding invalidates a cached object ID everywhere at once. That is the reason to resolve object IDs from OracleRegistry at runtime.

Freshness windows

Predict measures freshness against the wall clock, not against how recently a value changed, and the age of an observation is now - source_timestamp_ms, the provider's per-update time. The deployed configuration, verified 2026-09-10 on both networks, sets these windows on ProtocolConfig:

SettingValueMeaning
use_pyth_spot_for_forwardtrueA fresh Pyth spot reanchors the Block Scholes forward basis
pyth_spot_freshness_ms2000Maximum age of a Pyth spot used to reanchor the forward
block_scholes_price_freshness_ms2000Maximum age of a Block Scholes spot or forward observation
block_scholes_svi_freshness_ms60000Maximum age of a Block Scholes SVI observation

Both 2,000 ms windows are the package defaults at this pin; the previous-generation predict-8-21 deployment ran both at 10,000 ms.

Block Scholes staleness is fatal to live pricing: a spot or forward outside its window aborts with EBlockScholesPriceStale, and an SVI observation outside its window aborts with EBlockScholesSVIStale. A stale Pyth spot is not fatal while use_pyth_spot_for_forward is on. Pricing ignores a missing, non-normalizable, or stale Pyth spot and uses the Block Scholes forward directly, which is also what happens when the setting is off.

These 4 values are mutable protocol state with no public getter, so read them rather than assuming the table above still holds. One route works: read the ProtocolConfig shared object directly. All 4 fields sit in its pricing_config field and come back in a single object query, over GraphQL or gRPC, against the protocolConfig object for the network you are on, which getConfig(network).objects.protocolConfig returns:

NetworkProtocolConfig
Mainnet0x55e8800bcb31b792683ca4385bd533716e2a9721364236a69ee2c3bee61dd8f2
Testnet0xfeda745dfdef2cd9721c2ff1e1de538d103bc6012469d0eec10ea5de67f636d0

One route that looks plausible returns nothing. The deployed package declares PricingConfigUpdated and EwmaConfigUpdated but emits them only when an admin changes a value. As of 2026-09-10 no admin setter has run on either network's ProtocolConfig, so an event query for either type returns an empty list, and polling events tells you nothing beyond that the values above are still the deployed ones. The public read services index the previous-generation predict-8-21 deployment, not these 2 deployments, so their GET /config is not a source for these values either.

The same-transaction write guard

Predict refuses to price against an observation written by the transaction that is doing the pricing. Every contributing observation carries the digest of the transaction that wrote it, and live pricing compares that digest to the current transaction's digest:

  • The Block Scholes spot observation
  • The Block Scholes forward observation for the market's expiry
  • The SVI observation for the market's expiry
  • The Pyth spot observation, when use_pyth_spot_for_forward is on and pricing uses it

A match aborts with pricing::EOracleWrittenInThisTransaction, error code 15. The guard means a caller cannot bundle an oracle write and a trade into one transaction and mint against a price it just placed. Split them into separate transactions.

Pricing errors

deepbook_predict::pricing raises the full set:

CodeNameRaised when
0EZeroForwardThe forward normalizes to zero
1ECannotBeNegativeA negative value appears where pricing requires a magnitude
2ENonPositiveVarianceTotal variance is not positive
3EInvalidRangeThe strike pair is inverted or otherwise invalid
4EBlockScholesPriceStaleBlock Scholes spot or forward outside the price freshness window
5EBlockScholesInputsInvalidAn input falls outside the pricing-safe envelope described below the table
6EPythSpotInvalidOn the re-anchor branch only, the normalized Pyth spot exceeds u64::MAX / 100
7EWrongPythFeedThe PythFeed is not the canonical binding
8EWrongBlockScholesValueStoreThe value store is not the canonical binding
9ELivePricingExpiredThe clock has reached or passed the market's expiry
10EBlockScholesSVIStaleSVI outside the SVI freshness window
11EWrongBlockScholesSVIStoreThe SVI store is not the canonical binding
12EBlockScholesPriceUnavailableNo Block Scholes price observation exists
13EBlockScholesSVIUnavailableNo Block Scholes SVI observation exists
14EBlockScholesMinVarianceInvalidThe smallest total variance the surface admits, min_variance_increment + a, is not positive
15EOracleWrittenInThisTransactionThis transaction wrote a contributing observation
16EBlockScholesInputTooWideA stored u128 provider value exceeds u64::MAX when Predict narrows it to pricing width

Of those codes, 3 are narrower than their names suggest, and confusing them sends you looking at the wrong input.

EBlockScholesInputsInvalid is the one pricing-safe envelope check, and it covers every bound at once:

  • Spot and forward are both strictly positive.
  • The forward is at most u64::MAX / 100, and at most 100 times the spot. That factor of 100 is the basis envelope.
  • The SVI magnitudes a, b, and m are each at most 100e9.
  • The SVI rho magnitude is at most 1e9.
  • Sigma falls between 1e6 and 100e9 inclusive. A sigma below the minimum raises this code, not code 14.

EBlockScholesInputTooWide is not an economic bound at all. It fires in the narrowing step, when a u128 value the provider stored does not fit in the u64 width Predict prices in. EBlockScholesMinVarianceInvalid fires only when the sum of the minimum variance increment and a is not positive, which is a shape problem in the surface rather than a bound on any one parameter.

EPythSpotInvalid is narrower still. It fires only on the forward re-anchor branch, and only when the normalized spot exceeds u64::MAX / 100. A missing, non-normalizable, or stale Pyth spot never reaches the assert: pricing silently ignores it and falls back to the Block Scholes forward. A zero spot passes the assert too.

Reference ticks and settlement

Outside of live pricing, 2 market functions read oracles, and both are permissionless:

set_reference_tick reads the exact Pyth observation at the market's reference_tick_source_timestamp_ms, which market creation set to the expiry minus one cadence period. With no observation at that exact timestamp the call aborts with expiry_market::EReferenceTickObservationMissing. The reference tick is the one finite boundary a market admits off its admission grid, and a caller can set it once: a second, different value aborts with strike_exposure::EReferenceTickAlreadySet. A repeat call that resolves to the same tick succeeds and returns that tick, but emits nothing, because ReferenceTickSet fires only when the call newly records the tick. Treat the event as the record of the first successful seeding, not as a receipt for every call.

try_settle is idempotent and returns a boolean rather than aborting on the common failure. It returns false before the expiry, and it returns true when the market is now settled or was already settled. A pool flush never blocks it: a market settles the moment it reaches expiry even while the flush that snapshotted it is still valuing other markets, and the only flush-related work it does is to clear a valuation stamp left behind by a superseded flush. Settlement resolves in 2 stages:

  1. The exact Pyth spot at the market's expiry timestamp. This is the preferred source and records settlement_source 0.
  2. If Pyth has nothing at that exact timestamp, the call waits a 30,000 ms grace period after the expiry, then tries the exact Block Scholes minute-boundary spot. That records settlement_source 1.

Because both stages read an exact timestamp rather than the latest observation, the settlement price does not depend on when anyone calls try_settle. Anyone can call it, and calling it again after settlement is harmless. The Block Scholes fallback keys on the provider's own value_timestamp: a spot whose source timestamp falls on a minute boundary lands in the exact-history table through apply_spot_batch and insert_at alike, and spot_at(expiry) finds it only if one of those writes carried that exact timestamp.

Settlement emits MarketSettled, which carries the settlement price and the source:

Trigger claim workers from MarketSettled rather than from an expiry timestamp. Until someone calls try_settle successfully, the market has no settlement price, is_settled returns false, and every settled-position flow aborts with expiry_market::EMarketNotSettled.