Skip to main content

Market Keys

MarketKey and RangeKey identify the internal position quantities stored in a PredictManager. Neither is an object. Both are plain copy, drop, store value types that you build inside a transaction, pass to a trading function, and discard.

Use MarketKey for binary positions keyed by oracle ID, expiry, strike, and direction. Use RangeKey for vertical ranges keyed by oracle ID, expiry, lower strike, and higher strike.

Choose a key

The instrument you want determines the key you build. Both instruments pay the same way, where a quantity of 1_000_000 pays 1 USD when the instrument wins and nothing when it loses.

Your view on settlementInstrumentConstructorWins when
Ends above one levelBinary upmarket_key::upsettlement > strike
Ends at or below one levelBinary downmarket_key::downsettlement <= strike
Lands between two levelsVertical rangerange_key::newlower < settlement <= higher

Pick a binary position for a one-sided directional view. It wins on an unbounded outcome, so it costs more per contract and wins more often.

Pick a vertical range for a view that settlement lands inside a band. A range wins on a bounded outcome, so it costs less per contract and wins less often. Ranges suit a view on where a price settles rather than which direction it moves.

A range is economically a spread of two binary positions, but do not build it that way. The protocol prices a range as one instrument through predict::mint_range, stores it as one row keyed by RangeKey, and adjusts vault exposure for the pairing. Two separate binary mints cost more gas, create two rows you must track independently, and price without that adjustment.

MarketKey

A MarketKey identifies one binary instrument: a single strike on a single oracle, in one direction.

Fields

FieldTypeDescription
oracle_idIDObject ID of the OracleSVI that defines the underlying asset and expiry.
expiryu64Expiration timestamp in milliseconds. Redundant with the oracle, but stored explicitly.
strikeu64Strike price in fixed point, on the oracle's configured grid.
directionu80 for up, 1 for down. Private, so read it with is_up() or is_down().

Constructors

public fun up(oracle_id: ID, expiry: u64, strike: u64): MarketKey
public fun down(oracle_id: ID, expiry: u64, strike: u64): MarketKey
public fun new(oracle_id: ID, expiry: u64, strike: u64, is_up: bool): MarketKey

up() and down() set the direction for you. new() takes a bool and normalizes it to the same 0 or 1 byte, so all three produce identical keys for the same inputs. Use new() when direction is a runtime value and the named constructors when it is fixed.

None of the three validates its arguments. They always succeed, even with a nonexistent oracle ID or an off-grid strike. Validation happens later, when you pass the key to predict::mint or predict::redeem. See Validation rules.

Read fields

public fun oracle_id(key: &MarketKey): ID
public fun expiry(key: &MarketKey): u64
public fun strike(key: &MarketKey): u64
public fun is_up(key: &MarketKey): bool
public fun is_down(key: &MarketKey): bool

is_up() and is_down() are exact complements. No third direction value exists, because the constructors are the only way to set the field.

Worked example

These values describe an up position on a BTC oracle expiring 2026-01-01, struck at 95,000 USD. Strikes use the same 1e9 fixed-point scale as oracle::spot_price and oracle::forward_price, so 1 USD is 1_000_000_000.

InputValueReads as
oracle_id0x7c2f9a1d8e3b45607f1a2c9d0b8e6534a7f10d92c3b8e45f6a0d17b93c2e8f41Example ID. Read the real one from the Predict server.
expiry17672256000002026-01-01T00:00:00Z in milliseconds
strike95_000_000_000_00095,000 USD

In Move:

let key = market_key::up(
oracle_id,
1767225600000,
95_000_000_000_000,
);

The following Testnet example builds the same key in a transaction block and passes it straight to predict::mint. Step 2 is the market_key::up call, and step 3 consumes its result:

import { Transaction } from '@mysten/sui/transactions';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { client } from './client.js';
import { PREDICT, type ActiveOracle } from './config.js';

// Deposits DUSDC into the manager and mints one binary "up" position, in a
// single PTB. `dusdcCoinId` is a DUSDC coin object owned by the signer.
export async function mintBinaryUp(params: {
signer: Ed25519Keypair;
managerId: string;
oracle: ActiveOracle;
dusdcCoinId: string;
depositAmount: bigint; // DUSDC base units (6 decimals)
quantity: bigint; // position quantity
}) {
const { signer, managerId, oracle, dusdcCoinId, depositAmount, quantity } =
params;
const tx = new Transaction();

// 1. Split the deposit amount off a DUSDC coin and deposit it into the manager.
const [deposit] = tx.splitCoins(tx.object(dusdcCoinId), [depositAmount]);
tx.moveCall({
target: `${PREDICT.packageId}::predict_manager::deposit`,
typeArguments: [PREDICT.quoteType],
arguments: [tx.object(managerId), deposit],
});

// 2. Build the MarketKey for an "up" binary position.
const key = tx.moveCall({
target: `${PREDICT.packageId}::market_key::up`,
arguments: [
tx.pure.id(oracle.oracleId),
tx.pure.u64(oracle.expiry),
tx.pure.u64(oracle.strike),
],
});

// 3. Mint the position, paying from the manager's deposited balance.
tx.moveCall({
target: `${PREDICT.packageId}::predict::mint`,
typeArguments: [PREDICT.quoteType],
arguments: [
tx.object(PREDICT.predictObjectId),
tx.object(managerId),
tx.object(oracle.oracleId),
key,
tx.pure.u64(quantity),
tx.object.clock(),
],
});

const result = await client.core.signAndExecuteTransaction({
transaction: tx,
signer,
include: { effects: true },
});
if (result.$kind === 'FailedTransaction') {
throw new Error('mint transaction failed');
}
return result.Transaction;
}

Pass the key result straight into the next moveCall as an argument. A key is a value, not an object, so never wrap it in tx.object.

Click to open
Source for the MarketKey struct and functions

RangeKey

A RangeKey identifies one vertical range: a band between two strikes on a single oracle.

Fields

FieldTypeDescription
oracle_idIDObject ID of the OracleSVI that defines the underlying asset and expiry.
expiryu64Expiration timestamp in milliseconds.
lower_strikeu64Bottom of the band, exclusive at settlement.
higher_strikeu64Top of the band, inclusive at settlement.

RangeKey has no direction field. A bull-call range and a bear-put range with the same strikes are identical to the vault and share one row. Do not model them as separate holdings.

Constructor and reads

public fun new(oracle_id: ID, expiry: u64, lower_strike: u64, higher_strike: u64): RangeKey
public fun oracle_id(key: &RangeKey): ID
public fun expiry(key: &RangeKey): u64
public fun lower_strike(key: &RangeKey): u64
public fun higher_strike(key: &RangeKey): u64

Unlike the MarketKey constructors, range_key::new validates. It aborts with EInvalidStrikes when lower_strike is not less than higher_strike. Equal strikes fail, so a zero-width band cannot exist. This abort happens during key construction, so a bad range fails before the transaction reaches mint_range.

Why the band is half-open

A range pays when settlement lands in (lower_strike, higher_strike]. That asymmetry follows from the binary rules rather than from a separate convention. The vault records a range as a long up leg at the lower strike plus a long down leg at the higher strike. The up leg wins when settlement > lower, and the down leg wins when settlement <= higher. Both conditions hold exactly when settlement sits inside the half-open band.

A settlement exactly at lower_strike loses. A settlement exactly at higher_strike wins.

Worked example

A range on the same oracle, covering 90,000 USD to 100,000 USD:

InputValueReads as
lower_strike90_000_000_000_00090,000 USD
higher_strike100_000_000_000_000100,000 USD

The following Testnet example builds a RangeKey with range_key::new and mints against it. The four arguments follow the field order in the preceding table:

import { Transaction } from '@mysten/sui/transactions';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { client } from './client.js';
import { PREDICT, type ActiveOracle } from './config.js';

export async function mintRange(params: {
signer: Ed25519Keypair;
managerId: string;
oracle: ActiveOracle;
lowerStrike: bigint;
higherStrike: bigint;
quantity: bigint;
}) {
const { signer, managerId, oracle, lowerStrike, higherStrike, quantity } = params;
const tx = new Transaction();

const key = tx.moveCall({
target: `${PREDICT.packageId}::range_key::new`,
arguments: [
tx.pure.id(oracle.oracleId),
tx.pure.u64(oracle.expiry),
tx.pure.u64(lowerStrike),
tx.pure.u64(higherStrike),
],
});

tx.moveCall({
target: `${PREDICT.packageId}::predict::mint_range`,
typeArguments: [PREDICT.quoteType],
arguments: [
tx.object(PREDICT.predictObjectId),
tx.object(managerId),
tx.object(oracle.oracleId),
key,
tx.pure.u64(quantity),
tx.object.clock(),
],
});

const result = await client.core.signAndExecuteTransaction({
transaction: tx,
signer,
include: { effects: true },
});
if (result.$kind === 'FailedTransaction') throw new Error('mint_range failed');
return result.Transaction;
}
Click to open
Source for the RangeKey struct and functions

Validation rules

Building a key almost never fails. Using one does. predict::mint and predict::redeem call assert_key_matches, and the range functions call assert_range_key_matches. Both compare the key against the OracleSVI you passed in the same call, then check every strike against that oracle's grid.

Each oracle registers a strike grid at creation with a minimum strike and a tick size. The grid always spans 100,000 ticks:

max_strike = min_strike + tick_size * 100_000

A strike is valid when it sits inside [min_strike, max_strike] and lands exactly on a tick:

(strike - min_strike) % tick_size == 0

Build a valid key

  1. Fetch a live oracle from the Predict server. Read its object ID, expiry, and strike grid.
  2. Set oracle_id to that oracle's object ID. A mismatch aborts with EMarketKeyOracleMismatch or ERangeKeyOracleMismatch.
  3. Set expiry to the value the oracle reports. Do not compute or round it. A mismatch aborts with EMarketKeyExpiryMismatch or ERangeKeyExpiryMismatch.
  4. Snap each strike to the grid so it satisfies both conditions above. An off-grid or out-of-range strike aborts with EInvalidStrike.
  5. For a range, confirm lower_strike < higher_strike before you call range_key::new. The constructor checks both strikes against the grid, so the narrowest valid band is one tick wide.
  6. Confirm the oracle is still live. A key can be perfectly valid and still fail on EOracleStale, EOracleInactive, EOracleExpired, or EOracleSettled.

Using the grid from the earlier example, with a minimum strike of 50,000 USD and a 100 USD tick:

StrikeValueResult
95,000 USD95_000_000_000_000Valid. Exactly 450 ticks above the minimum.
95,050 USD95_050_000_000_000Aborts with EInvalidStrike. Lands 450.5 ticks above the minimum.
40,000 USD40_000_000_000_000Aborts with EInvalidStrike. Below the minimum strike.

Keys as table keys

Both types carry copy, drop, and store, the exact abilities Sui requires of a Table key. That is why the protocol can store positions as table rows instead of as objects:

TableLocationType
positionsPredictManagerTable<MarketKey, u64>
range_positionsPredictManagerTable<RangeKey, u64>

predict::mint increases the row at your MarketKey, and predict::redeem decreases it. The vault tracks the same trades separately in per-oracle strike matrices, so the manager records what a user holds while the vault records what the protocol owes. See Predict Manager and Vault.

Equality

Two keys are equal when every field matches. Move compares them structurally with ==, and Sui tables compare the BCS serialization of the key, which follows declaration order. For MarketKey that order is oracle_id, expiry, strike, direction.

Because the constructors normalize direction to 0 or 1 and expose no other path to set it, two keys built from the same inputs always serialize to the same bytes. No equivalent-but-different encoding exists.

Off chain, compare keys by their BCS bytes rather than by field-by-field JSON. To read one specific row, call predict_manager::position with a key you build in the same transaction. To enumerate every row, list the dynamic fields of the table's id, where each field name is a BCS-serialized key, or read the indexed portfolio endpoint from the Predict server.

caution

expiry duplicates information the oracle already holds, and it is part of the key. A key with the correct oracle but a wrong expiry is a different table row, not an error at construction time. It reads back as 0 from position() and aborts with an expiry mismatch at mint. Always copy the expiry the oracle reports.

Error codes

CodeConstantModuleCause
0EInvalidStrikesrange_keylower_strike is not less than higher_strike.
0EMarketKeyOracleMismatchoracle_configThe key's oracle_id does not match the oracle passed in the call.
1EMarketKeyExpiryMismatchoracle_configThe key's expiry does not match the oracle's expiry.
2EInvalidStrikeoracle_configA strike is outside the grid or not on a tick.
9ERangeKeyOracleMismatchoracle_configThe range key's oracle_id does not match the oracle passed in the call.
10ERangeKeyExpiryMismatchoracle_configThe range key's expiry does not match the oracle's expiry.

Error numbers repeat across modules, so resolve an abort against the module in the abort location rather than against the number alone.