Sessions
The deepbook_sessions package lets the owner of a canonical DeepBook account authorize an ephemeral address to submit trades for that account until a fixed expiration time. An application that would otherwise prompt a wallet on every trade grants one session key, signs locally for the life of the grant, and lets the grant lapse.
Sessions is an account app. It stores grants in the account's app-local data slot and generates account app authorization only inside its own trading wrappers, so a session caller never receives a reusable Auth value and the package exposes no withdrawal or arbitrary mutation call. Every function on this page is a public fun invoked as a moveCall.
The surface spans 2 modules:
deepbook_sessions::sessions: Grant storage, the lifecycle calls, and every trading wrapper (source).deepbook_sessions::session_config: The sharedSessionsConfigobject and the package version floor (source).
Sui Mainnet and Sui Testnet each run a separate deployment of the package from identical sources, and the Move snippets on this page pin the Mainnet source commit, which is also the deepbook-predict-mainnet branch. In TypeScript, getSessionsConfig(network) from @mysten/deepbook-v3/sessions resolves 'mainnet' or 'testnet' from version 2.3.0 and returns both IDs, plus the account registry that authorizes the app, the Predict protocol config, and the 2 extra IDs the DeepBook spot wrappers take. The full tables are on Contract Information:
| Object | Mainnet | Testnet |
|---|---|---|
sessions package | 0xebfa125baee571c1f8c903c1dd54a5bc57949db82469b836c32eca251756c82a | 0x10c91d168dc4a04357f9d23795a204092ca46e5b97f8b9ef26fea95664d5bb38 |
SessionsConfig | 0x0c5f64365b3bf1f67827d6975893190079cc048247f9ff0b08390aa9ee697c5c | 0x63cde7c7d846f15c51802ec3d44ba44918658e3155b0d2e0cb26d6fc1b4dadd8 |
A grant lives on one network's account. Sessions authorized on the previous-generation predict-8-21 deployment are not visible on either of these deployments.
Authority model
A session key is trading authority over the whole account. It is not a scoped permission, and nothing in the package narrows it.
A session key can trade the account's entire balance. Nothing caps notional, restricts which pools or markets are reachable, or bounds loss to adverse pricing. Fund an account that hands out ephemeral session keys with only what you are willing to put at risk, and treat the key material with the same care as the owner key for everything except withdrawal.
The grant itself carries 2 values and nothing else, the session address and its expiration timestamp in milliseconds. There is no market list, no notional ceiling, no price band, and no per-call budget to configure, because none of those fields exists.
What a session can and cannot do:
| A session can | A session cannot |
|---|---|
| Mint and redeem Predict positions on any market, at any size the balance covers, with caller-chosen price bounds. | Withdraw funds to an address. Value leaving the account still needs owner authority. |
Place and cancel DeepBook spot orders in any pool, with a caller-chosen price_limit. | Grant a session, revoke a session, or extend its own expiry. |
| Sweep settled spot proceeds back into account custody. | Outlive its expiration timestamp. |
The spot wrappers are the widest surface. Each one takes a caller-chosen Pool and routes into deepbook_core_account, which first settles the account's pending accumulator funds for the pool's base asset, quote asset, and DEEP, then withdraws the account's whole balance of all 3 into the embedded balance manager for the duration of the call, and finally sweeps free balances back. The trade sits between those 2 steps, so a single session-signed spot order reaches everything the account holds in those coin types, stored plus unsettled, at whatever price_limit the caller supplies.
Revocation and expiration stop future wrapper calls. Neither unwinds a position, cancels a resting order, or reverses a trade that already executed.
The check every wrapper runs
Each trading wrapper resolves authority through one private helper before it touches Predict or DeepBook. The helper checks 3 conditions, which must all hold:
- The executing sessions package version is at or above the shared
SessionsConfig.version_watermark. - The transaction sender has a stored grant on the supplied account.
- The current clock timestamp is strictly less than that grant's expiration.
packages/sessions/sources/sessions.move. You probably need to run `pnpm prebuild` and restart the site.The helper mints the Auth value and the wrapper consumes it inside the same call, so the session key never holds a value it could reuse in a later command or a later transaction.
Deauthorizing the app is a pause, not a kill switch
The registry administrator must authorize SessionsApp on the account registry before any trading wrapper can generate account authorization. That authorization is package-wide, held by the registry administrator, and it is the emergency stop for the whole lineage: deauthorize_app removes the allowlist entry, and from that moment every version of the sessions package fails inside the trading wrappers, aborting EAppNotAuthorized once a call clears the version and session checks ahead of it.
deauthorize_app does not clear stored grants. Every SessionsData slot survives untouched, so re-authorizing SessionsApp makes every still-unexpired grant live again at once, with no further owner action. Treat deauthorization as a pause. To end a specific delegation, the account owner must call revoke_session.
packages/account/sources/account_registry.move. You probably need to run `pnpm prebuild` and restart the site.Until the administrator authorizes SessionsApp, and again after a deauthorization, the split is clean: the trading wrappers abort, while authorize_session, revoke_session, and session_expiration_ms keep working, because they run on owner authority or no authority at all. An owner can therefore still inspect and clean up grants while the pause lasts.
Session lifecycle
The whole lifecycle takes 3 calls. Owner authority derives from the transaction sender, so the account owner signs authorize_session and revoke_session. That derivation is what makes the flow work for accounts owned by an external address and not for object-owned accounts.
packages/sessions/sources/sessions.move. You probably need to run `pnpm prebuild` and restart the site.Bounds
The contract enforces 4 bounds, and the SDK exports the duration ceiling and the address limit as MAX_SESSION_DURATION_MS and MAX_SESSIONS_PER_ACCOUNT:
| Bound | Value | Abort |
|---|---|---|
| Maximum duration | 30 days, in milliseconds | EInvalidSessionDuration |
| Minimum duration | Greater than zero | EInvalidSessionDuration |
| Distinct addresses stored per account | 20 | ESessionLimitExceeded |
| Expiry comparison | Strict, now < expires_at_ms | ESessionNotAuthorized |
The way the contract applies those bounds has 4 consequences:
- Absolute expiry:
authorize_sessionreads theClockat execution time and storestimestamp_ms + duration_ms, so a queued or retried transaction still gets its full duration rather than a window measured from when you built it. - Strict expiry: The grant is dead at its expiration timestamp. The wrapper asserts
now < expires_at_ms, notnow <= expires_at_ms. A grant whose expiry equals the current timestamp is already unusable. - Re-authorization: Calling
authorize_sessionagain for an address that already holds a grant overwrites its expiration in place and consumes no additional slot, which is the intended way to extend a running session. - Permanent data slot: Once the first grant attaches
SessionsDatato an account, the slot stays attached even after the map empties.
The transaction result cannot confirm a revocation
revoke_session removes a stored grant whether it is active or already expired, frees its slot, and emits SessionRevoked. Revoking an address that holds no grant is a silent no-op: the call does not abort and emits no event, so a successful transaction proves nothing about whether the call removed a grant or none was there.
Read before and after when the difference matters. session_expiration_ms returns the stored expiration for a known address and none for one the owner never authorized or has since revoked. It does not classify the timestamp as active or expired, so compare it with the current time yourself.
Revocation deliberately takes no SessionsConfig and has no version gate, so an owner can still remove grants after the watermark retires a package version.
Authorize a session with the SDK
import {
MAX_SESSION_DURATION_MS,
SessionsContract,
getSessionsConfig,
} from '@mysten/deepbook-v3/sessions';
import { Transaction } from '@mysten/sui/transactions';
import { NETWORK } from './config.js';
// One contract instance drives every sessions call in these examples.
// `getSessionsConfig` returns the deployed sessions package, the shared
// `SessionsConfig` object, the account registry the app is authorized against,
// the Predict protocol config, and the two extra IDs the DeepBook spot wrappers
// need. It resolves both recorded networks; an unrecorded one throws.
export const SESSIONS_CONFIG = getSessionsConfig(NETWORK);
export const sessions = new SessionsContract(SESSIONS_CONFIG);
// The owner's shared `AccountWrapper` ID, derived from the owner address with no
// chain read. Every builder below takes it as `wrapperId`.
export function wrapperId(owner: string): string {
return sessions.deriveAccountWrapperId(owner);
}
// Authorize `session` to trade the owner's account until `now + durationMs`.
//
// A grant is trading authority over the whole account, not a scoped permission.
// The session key cannot withdraw to an address, cannot grant or revoke
// sessions, and cannot outlive its expiry, but within those limits it chooses
// the market, the size, and the price bounds, and the spot wrappers reach the
// account's entire Base, Quote, and DEEP balance. Fund an account that hands out
// ephemeral session keys with only what you are willing to put at risk.
//
// The owner must sign, because the contract derives owner authority from the
// transaction sender. Re-authorizing an address that already holds a grant
// replaces its expiry in place and consumes no additional slot.
export function authorizeSession(params: {
owner: string;
session: string;
durationMs: number;
}): Transaction {
const { owner, session, durationMs } = params;
// The chain asserts the same bounds and aborts `EInvalidSessionDuration`.
// Checking here turns a wasted transaction into a local error.
if (durationMs <= 0 || durationMs > MAX_SESSION_DURATION_MS) {
throw new Error(
`durationMs must be greater than 0 and at most ${MAX_SESSION_DURATION_MS} (30 days), got ${durationMs}.`,
);
}
const tx = new Transaction();
tx.setSender(owner);
tx.add(sessions.authorizeSession({ wrapperId: wrapperId(owner), session, durationMs }));
// Ready to sign with the owner's key. Nothing here signs it.
return tx;
}
// A short-lived grant for one trading run. Expiry is computed from the onchain
// clock at execution time, not from when you build the transaction, so a queued
// or retried transaction still gets its full duration.
export function authorizeForOneHour(owner: string, session: string): Transaction {
return authorizeSession({ owner, session, durationMs: 60 * 60 * 1000 });
}
Revoke and confirm with the SDK
import { bcs } from '@mysten/sui/bcs';
import { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { sessions, wrapperId } from './sessions-authorize.js';
// Revoke one grant. The owner signs, the slot frees immediately, and the address
// stops being able to trade from the next transaction on.
//
// Revocation takes no `SessionsConfig` and is not version gated, so it keeps
// working after the sessions package is retired by a version bump. It does not
// unwind anything the session already did: positions, resting spot orders, and
// executed trades all survive.
export function revokeSession(owner: string, session: string): Transaction {
const tx = new Transaction();
tx.setSender(owner);
tx.add(sessions.revokeSession({ wrapperId: wrapperId(owner), session }));
return tx;
}
// Read one address's absolute expiry, in milliseconds since the epoch.
//
// `session_expiration_ms` returns `Option<u64>`: `none` for an address that was
// never granted or has been revoked, and the stored timestamp otherwise. It does
// not classify the timestamp, so compare it with the current time yourself, and
// remember the comparison is strict: the grant is dead AT `expiresAtMs`.
//
// The call is a `public fun` with a return value rather than an `entry` call, so
// simulate it and decode the returned BCS instead of executing it.
export async function sessionExpirationMs(
owner: string,
session: string,
): Promise<bigint | null> {
const tx = new Transaction();
// A simulation needs a sender; any address works for a read.
tx.setSender(owner);
tx.add(sessions.sessionExpirationMs({ wrapperId: wrapperId(owner), session }));
const result = await client.core.simulateTransaction({
transaction: tx,
// Public non-entry functions are only inspectable with checks disabled.
checksEnabled: false,
include: { commandResults: true },
});
if (result.$kind === 'FailedTransaction') {
throw new Error(`session_expiration_ms simulation failed for ${session}.`);
}
const returned = result.commandResults?.[0]?.returnValues?.[0]?.bcs;
if (!returned) throw new Error('simulateTransaction returned no command results.');
const expiry = bcs.option(bcs.u64()).parse(returned);
return expiry === null ? null : BigInt(expiry);
}
// Whether `session` can trade right now. `false` covers both an address that
// never held a grant and one whose grant has expired or been revoked.
export async function isSessionActive(
owner: string,
session: string,
nowMs: number = Date.now(),
): Promise<boolean> {
const expiry = await sessionExpirationMs(owner, session);
return expiry !== null && BigInt(nowMs) < expiry;
}
// Confirm a revocation by reading, because the transaction result cannot.
//
// Revoking an address that holds no grant is a silent no-op: it does not abort
// and it emits no `SessionRevoked` event, so a successful transaction proves
// nothing about whether a grant was removed or was never there. Read before and
// after when the difference matters, for example when you are auditing a key you
// believe you granted.
export async function revokeAndConfirm(
owner: string,
session: string,
): Promise<{ tx: Transaction; hadGrant: boolean }> {
const before = await sessionExpirationMs(owner, session);
return { tx: revokeSession(owner, session), hadGrant: before !== null };
}
Enumerate an account's grants
There is no bulk onchain read. session_expiration_ms answers one address at a time, so listing every grant means fetching the account's app-data field and decoding it offchain. The data is a VecMap<address, u64> inside a SessionsData value:
packages/sessions/sources/sessions.move. You probably need to run `pnpm prebuild` and restart the site.Getting there takes 4 steps, and the SDK exposes each one:
- Derive the canonical account address from the owner with
deriveAccountId. - Derive the
DataKey<SessionsApp>field ID under that address withderiveSessionsFieldId. - Fetch that object with the core
getObject, including Binary Canonical Serialization (BCS) content. - Decode the bytes with the static
decodeSessions, then split the result withactiveSessionsandexpiredSessions.
That path has 3 traps:
- Account, not wrapper: The field hangs off the account, not the wrapper. An owner has 2 derived objects, the shared
AccountWrapperthat every call takes as an argument, and the canonicalAccountidentity that app data attaches to. Grants hang off the second one. Deriving the field under the wrapper address yields an ID that points at nothing. - Plain dynamic field:
account::attachwrites the slot withdynamic_field::add, whereas the registry claims the account and wrapper IDs throughderived_object::claim. Derive it withderiveDynamicFieldID. UsingderiveObjectIDwraps the type tag in aDerivedObjectKey, producing a different, empty address. - Whole-field decoding: Decode the whole field, not the inner value.
decodeSessionstakes the completeField<DataKey, SessionsData>bytes the core API returns.DataKeyis empty in source, but Move inserts a hiddendummy_field: bool, so one zero byte sits between the field ID and the value. Decoding the value alone reads the field ID's first byte as the map length. The decoder also throws on truncated or padded bytes rather than returning an empty list, so an empty result means the account genuinely holds no grants.
Split the result with the 2 helpers rather than by hand. The chain asserts now < expires_at_ms, so activeSessions filters on nowMs < expiresAtMs and expiredSessions on nowMs >= expiresAtMs. A filter written the intuitive way, as nowMs > expiresAtMs, leaves the grant expiring exactly at nowMs classified as neither, and that grant then occupies a slot forever.
Nothing prunes expired grants. Time passing executes no Move code, so a dead grant counts toward the 20-address limit until the owner revokes it, and the 21st authorize_session aborts ESessionLimitExceeded even when every stored grant is long dead. List, revoke what has expired, then grant.
List grants and reclaim slots with the SDK
import type { SessionGrant } from '@mysten/deepbook-v3/sessions';
import { MAX_SESSIONS_PER_ACCOUNT, SessionsContract } from '@mysten/deepbook-v3/sessions';
import { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { sessions, wrapperId } from './sessions-authorize.js';
// Every grant an account holds.
//
// There is no bulk onchain read: `session_expiration_ms` answers one address at a
// time. Listing means fetching the account's `DataKey<SessionsApp>` dynamic field
// and decoding it locally. Two details make or break this:
//
// 1. The field hangs off the derived ACCOUNT address, not off the wrapper. They
// are different objects. `deriveSessionsFieldId` derives the account first,
// then the field, so pass the owner address and let it do both steps.
// 2. `decodeSessions` takes the whole `Field<DataKey, SessionsData>` bytes that
// `getObject` returns, not the inner value. It throws on truncated bytes
// rather than decoding to an empty list, so an empty result means the account
// really holds no grants.
//
// The field does not exist until the first `authorize_session`, so a missing
// object is an empty list rather than an error. Once attached it stays attached,
// even after every grant is revoked.
export async function listGrants(owner: string): Promise<SessionGrant[]> {
const fieldId = sessions.deriveSessionsFieldId(owner);
let content: Uint8Array | undefined;
try {
const { object } = await client.core.getObject({
objectId: fieldId,
include: { content: true },
});
content = object.content;
} catch {
// No sessions data attached to this account yet.
return [];
}
if (!content) return [];
return SessionsContract.decodeSessions(content);
}
// Split a listing at the current time. Use the helpers rather than comparing by
// hand: the chain asserts `now < expiresAtMs`, so a grant is dead AT its expiry,
// and a filter written as `nowMs > expiresAtMs` leaves the grant expiring exactly
// at `nowMs` occupying a slot forever.
export async function grantsByState(
owner: string,
nowMs: number = Date.now(),
): Promise<{ active: SessionGrant[]; expired: SessionGrant[]; slotsFree: number }> {
const grants = await listGrants(owner);
return {
active: SessionsContract.activeSessions(grants, nowMs),
expired: SessionsContract.expiredSessions(grants, nowMs),
// Expired grants still occupy slots, so free slots count against every
// stored address, not just the live ones.
slotsFree: MAX_SESSIONS_PER_ACCOUNT - grants.length,
};
}
// Reclaim the slots expired grants are still holding.
//
// Time passing executes no Move code, so nothing prunes expired entries. They
// count toward the 20 stored addresses until the owner revokes them, and the
// twenty-first `authorize_session` aborts `ESessionLimitExceeded` even when every
// stored grant is long dead. Run this before granting on a busy account.
//
// Returns null when there is nothing to reclaim, so the caller does not sign an
// empty transaction.
export async function reclaimExpiredSlots(
owner: string,
nowMs: number = Date.now(),
): Promise<Transaction | null> {
const { expired } = await grantsByState(owner, nowMs);
if (expired.length === 0) return null;
const tx = new Transaction();
tx.setSender(owner);
for (const grant of expired) {
tx.add(sessions.revokeSession({ wrapperId: wrapperId(owner), session: grant.session }));
}
return tx;
}
Predict wrappers
An active session can call 4 Predict wrappers:
| Wrapper | Predict call | Returns |
|---|---|---|
mint_exact_quantity | expiry_market::mint_exact_quantity | The new order ID, as u256. |
mint_exact_amount | expiry_market::mint_exact_amount | The new order ID, as u256. |
redeem_live | expiry_market::redeem_live | Option<u256>, the replacement order ID when a partial close leaves quantity open. |
redeem_settled | expiry_market::redeem_settled | Nothing. |
Each one validates the package version and the session against the supplied account, generates app authorization internally, and passes it straight into the Predict function of the same name. Every market parameter stays caller-selected, and Predict performs all of the validation it normally would, including the gates a session caller cannot see from this package: mint_exact_quantity, mint_exact_amount, and redeem_live abort with protocol_config::ETradeWindowClosed inside the last no_trade_window_ms before expiry, deployed at 2,000 ms on both networks, and every wrapper aborts with ESnapshotInProgress if you compose it into the transaction that snapshots a pool flush. redeem_settled is outside the trade window, so a session can always close a settled position. Compared with the owner-signed Predict call, the wrapper takes no Auth argument and adds account_registry and sessions_config.
pricer is a programmable transaction block result rather than an object ID. Load it with expiry_market::load_live_pricer in a preceding command of the same transaction, exactly as the owner-signed flow does. See Predict for the market parameters, the tick pair, and the fee model behind the caps.
Slippage bounds differ from the facade
The @mysten/deepbook-v3/sessions builders and the @mysten/deepbook-v3/predict facade disagree about slippage protection in both directions, and both differences favor the session key holder being explicit:
- Required mint caps: The session mint builders require both caps.
mintExactQuantitytakesmaxCostandmaxProbabilityas required parameters, andmintExactAmountrequiresmaxCost. The/predictfacade instead sendsU64_MAXfor a cap you omit, which is a cap that can never trip. On a session builder there is no default to fall into. - Close-side floors on
redeemLive:minProbabilityandminProceedsare optional parameters that reach the contract. The/predictfacade sends zero for both and offers no option to raise them. Omitting a floor on the session builder also means zero, and zero on a delegated key closes the position at any price the mark gives. Pass real floors on anything a session key can reach.
Predict wrapper source
packages/sessions/sources/sessions.move. You probably need to run `pnpm prebuild` and restart the site.Trade as a session key with the SDK
import type { MarketDescriptor, MintQuote, Side } from '@mysten/deepbook-v3/predict';
import {
binaryRangeTicks,
getConfig,
loadLivePricer,
priceToRaw,
probabilityToRaw,
toGeneratedConfig,
usdcToRaw,
} from '@mysten/deepbook-v3/predict';
import { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { NETWORK, UNDERLYING } from './config.js';
import { admissibleStrike, tradeableMarket } from './markets.js';
import { SESSIONS_CONFIG, sessions, wrapperId } from './sessions-authorize.js';
// The Predict session wrappers take the same market parameters as Predict itself,
// so they need the Predict deployment's IDs alongside the sessions IDs. The
// `/predict` subpath exports the projection and the pricer loader precisely so
// these wrappers can be composed.
const PREDICT = getConfig(NETWORK);
const GENERATED = toGeneratedConfig(PREDICT);
const FEEDS = PREDICT.underlyings[UNDERLYING];
// Mint a directional position as the session key.
//
// Three things differ from the owner-signed `/predict` flow:
//
// 1. The session key is the sender. The wrapper derives the caller from the
// sender, mints app authorization internally, and consumes it in the same
// call, so no `Auth` value is built or passed.
// 2. `pricer` is a PTB result, not an object ID. Load it with
// `loadLivePricer` in a preceding command of the same transaction.
// 3. `maxCost` and `maxProbability` are required. The `/predict` facade defaults
// a missing cap to `U64_MAX`, which is no cap at all; the session builder
// makes you name both. Quantities and costs are raw six-decimal USDC, and
// probabilities are raw fixed point at 1e9.
export async function mintAsSession(params: {
owner: string;
session: string;
side: Side;
// Maximum payout in USD, at $1 per contract. Must be a whole $0.01 lot.
quantity: number;
// Omit to trade at the market's onchain reference price, the window anchor.
targetStrikeUsd?: number;
}): Promise<{ tx: Transaction; quote: MintQuote; descriptor: MarketDescriptor }> {
const { owner, session, side, quantity, targetStrikeUsd } = params;
const market = await tradeableMarket();
// The session wrappers take absolute ticks, so resolve the strike to a number
// before converting. `tradeableMarket` already skips markets whose reference
// price is unseeded.
if (market.referencePrice === null) {
throw new Error(`Market ${market.id} has no reference price yet.`);
}
const strikeUsd =
targetStrikeUsd === undefined
? market.referencePrice
: admissibleStrike(market, targetStrikeUsd);
const { lowerTick, higherTick } = binaryRangeTicks(
priceToRaw(strikeUsd),
side,
priceToRaw(market.tickSize),
);
// Quote through the owner path first. The quote dry-runs the same market, the
// same account, and the same fee path, so its `cost` is the figure to size the
// cap against. Quote immediately before sending: on the short cadences the
// entry probability moves fast.
const descriptor: MarketDescriptor = {
underlying: UNDERLYING,
expiryMs: market.expiryMs,
marketId: market.id,
side,
strike: strikeUsd,
};
const quote = await client.predict.read.quoteMint(owner, descriptor, { quantity });
const tx = new Transaction();
// The session key signs, not the owner.
tx.setSender(session);
const pricer = tx.add(
loadLivePricer(GENERATED, {
expiryMarketId: market.id,
pythFeed: FEEDS.pythFeed,
blockScholesValueStore: FEEDS.blockScholesValueStore,
blockScholesSviStore: FEEDS.blockScholesSviStore,
}),
);
tx.add(
sessions.mintExactQuantity({
expiryMarketId: market.id,
wrapperId: wrapperId(owner),
protocolConfig: SESSIONS_CONFIG.protocolConfig,
pricer,
lowerTick,
higherTick,
quantity: usdcToRaw(quantity),
// All-in debit ceiling, one percent above the quoted cost.
maxCost: usdcToRaw(Math.ceil(quote.cost * 1.01 * 1e6) / 1e6),
// Independent ceiling on the fill price, 0 to 1 per $1 of payout.
maxProbability: probabilityToRaw(
Math.min(1, Number((quote.entryProbability * 1.02).toFixed(9))),
),
}),
);
return { tx, quote, descriptor };
}
// Close part or all of a live position as the session key.
//
// `minProbability` and `minProceeds` are close-side floors, and omitting either
// sends zero. Zero on a delegated key means the position closes at whatever the
// mark gives, so pass real floors on anything a session key can reach. The
// `/predict` facade sends zero for both and exposes no way to raise them, which
// is why the session builder is the one that can protect a close.
export async function closeAsSession(params: {
owner: string;
session: string;
expiryMarketId: string;
orderId: bigint;
// Payout quantity to close, in USD. Must be a whole $0.01 lot.
quantity: number;
// Minimum acceptable fill probability, 0 to 1.
minProbability: number;
// Minimum acceptable USDC proceeds for the whole close.
minProceeds: number;
}): Promise<Transaction> {
const { owner, session, expiryMarketId, orderId, quantity } = params;
const tx = new Transaction();
tx.setSender(session);
const pricer = tx.add(
loadLivePricer(GENERATED, {
expiryMarketId,
pythFeed: FEEDS.pythFeed,
blockScholesValueStore: FEEDS.blockScholesValueStore,
blockScholesSviStore: FEEDS.blockScholesSviStore,
}),
);
tx.add(
sessions.redeemLive({
expiryMarketId,
wrapperId: wrapperId(owner),
protocolConfig: SESSIONS_CONFIG.protocolConfig,
pricer,
orderId,
closeQuantity: usdcToRaw(quantity),
minProbability: probabilityToRaw(params.minProbability),
minProceeds: usdcToRaw(params.minProceeds),
}),
);
// A partial close retires the order ID and returns a replacement as
// `Option<u256>`. Read it from the transaction result and store it, or the
// next close targets an order that no longer exists.
return tx;
}
// Claim a settled position as the session key. No pricer, because the settlement
// price is fixed, and no quantity, because a settled claim closes the order in
// full.
export function claimSettledAsSession(params: {
owner: string;
session: string;
expiryMarketId: string;
orderId: bigint;
}): Transaction {
const { owner, session, expiryMarketId, orderId } = params;
const tx = new Transaction();
tx.setSender(session);
tx.add(
sessions.redeemSettled({
expiryMarketId,
wrapperId: wrapperId(owner),
protocolConfig: SESSIONS_CONFIG.protocolConfig,
orderId,
}),
);
return tx;
}
DeepBook spot wrappers
An active session can also call 5 DeepBook spot wrappers that route through deepbook_core_account: place_limit_order, place_market_order, cancel_live_order, cancel_live_orders, and withdraw_settled_amounts. Order parameters stay caller-selected, and the account wrapper and DeepBook core validate them. This package does not duplicate the permissionless settled-amount withdrawal, because it needs no session authority.
The DeepBook admin-cap owner must authorize DeepbookCoreAccountApp in the supplied DeepBook registry before spot order placement works, separately from the SessionsApp authorization these wrappers already require. The 2 networks differ here, verified 2026-09-10. On Testnet the DeepBook registry 0x7c256edbda983a2cd6f946655f4bf3f00a41043993781f8674a7046e8c0e11d1 authorizes the wrapper's DeepbookCoreAccountApp, so the spot wrappers execute. On Mainnet the DeepBook registry 0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d does not authorize it, so the 5 spot wrappers abort there and the session surface on Mainnet is the 4 Predict wrappers until that changes.
Do not read that state out of the deployment manifest. The manifest is the audited initial-deployment snapshot, and its externalAuthorizations entry records whether the authorization was in place when the deployment ran, not whether it is in place now: both manifests record authorized: false, and Testnet has since gained the authorization. The DeepBook admin-cap owner grants it in a separate transaction after deployment, and the DeepBook registry is the live authority from that point on. Check the registry with deepbook::registry::assert_app_is_authorized<DeepbookCoreAccountApp> in a simulated transaction, using the deepbookRegistry and deepbookCoreAccountPackageId that getSessionsConfig(network) returns, rather than trusting the recorded flag or this page.
The TypeScript SDK generates these calls but does not wrap them on SessionsContract, because the SDK does not yet model the surrounding workflow of discovering the embedded balance manager, reading resting orders, and reading locked balances. Reach them through the generated sessionsMoveCalls namespace meanwhile, and watch the argument positions: place_limit_order and place_market_order carry deepbook_registry between pool and account_registry, which pushes account_registry to index 2 and sessions_config to index 4. The other 3 take no deepbook_registry, so they keep those arguments at 1 and 3, matching the Predict wrappers.
Spot wrapper signatures
packages/sessions/sources/sessions.move. You probably need to run `pnpm prebuild` and restart the site.Version governance
Publishing the package creates a shared SessionsConfig at the package's compiled-in version and transfers a SessionsAdminCap to the publisher. authorize_session and every trading wrapper assert that the executing package version is at or above the stored watermark:
packages/sessions/sources/session_config.move. You probably need to run `pnpm prebuild` and restart the site.Each package upgrade increments a compiled-in version constant. After clients move to the new package, the SessionsAdminCap holder calls bump_version_watermark on that package, which derives the target from the executing package rather than from an argument, so a caller cannot select an arbitrary version or use an older package to retire a newer one:
packages/sessions/sources/session_config.move. You probably need to run `pnpm prebuild` and restart the site.Advancing the watermark retires authorization and trading in older package versions. Reading expirations and revoking grants stay available, so owners can inspect and remove delegations without a trading-capable package version.
Events
The lifecycle emits 2 events. Predict and DeepBook core keep emitting their own trading events, and the wrappers do not duplicate them.
packages/sessions/sources/sessions.move. You probably need to run `pnpm prebuild` and restart the site.authorize_session emits SessionAuthorized for a new grant and for a re-authorization alike, carrying the new expires_at_ms in both cases. revoke_session emits SessionRevoked only when it actually removes an existing grant. Expiration emits nothing, because nothing executes when a deadline passes, and a no-op revocation emits nothing either. An indexer that wants a current view of live grants therefore has to apply expiry itself, using the timestamps the authorization events carry.
Errors
Error numbers repeat across modules, so resolve an abort against the module named in the abort location rather than against the number alone:
| Error | Value | Module | Raised when |
|---|---|---|---|
EInvalidSessionDuration | 0 | sessions | duration_ms is zero or greater than 30 days. |
ESessionNotAuthorized | 1 | sessions | The sender holds no grant on the supplied account, or the current timestamp is at or past the grant's expiration. |
ESessionLimitExceeded | 2 | sessions | The account already stores 20 distinct addresses and the address in the call is not one of them. |
EPackageVersionDisabled | 0 | session_config | The executing package version is below the shared configuration's watermark. |
EVersionWatermarkNotAdvanced | 1 | session_config | bump_version_watermark runs from a package whose version is not above the stored watermark. |
EAppNotAuthorized | 1 | account_registry | The account registry does not authorize SessionsApp, so no trading wrapper can generate authorization. |
For the account object these grants attach to, see Accounts and Custody. For the market parameters the Predict wrappers forward, see Predict.