Skip to main content

Spot Trading Workflow

Run a complete DeepBook spot order lifecycle on Sui Testnet with real transactions: create a BalanceManager, deposit, place a resting limit order, read its status, cancel it, then place a maker order and fill it with your own taker order, and withdraw. Each onchain step lists what to observe, so the page doubles as a verification checklist.

It uses the DEEP_SUI pool, which you can fund by finishing the Fees and Funding flow (Testnet SUI plus DEEP). The samples live in the examples/deepbook-spot package, type-checked with tsc --noEmit against @mysten/deepbook-v3 and @mysten/sui, and reference pools and coins by SDK key rather than hardcoding onchain IDs.

  • A current CLI: run suiup update sui, then sui --version to confirm it (a stale CLI causes version-skew errors).
  • Testnet SUI and DEEP on your address, from Fees and Funding.
  • The SDK installed: npm install @mysten/deepbook-v3 @mysten/sui.

Set up the client

The client references pools and coins by SDK key. Read-only calls work without a manager; trading needs one.

import { SuiGrpcClient } from '@mysten/sui/grpc';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { decodeSuiPrivateKey } from '@mysten/sui/cryptography';
import { deepbook, type DeepBookClient, type BalanceManager } from '@mysten/deepbook-v3';
import type { ClientWithExtensions } from '@mysten/sui/client';

export type DeepBookTestnetClient = ClientWithExtensions<{ deepbook: DeepBookClient }>;

export function getKeypair(privateKey: string): Ed25519Keypair {
const { secretKey } = decodeSuiPrivateKey(privateKey);
return Ed25519Keypair.fromSecretKey(secretKey);
}

// Testnet DeepBook client. The SDK ships Testnet package, coin, and pool
// constants, so you reference pools and coins by key (for example 'DEEP_SUI' or
// 'DEEP') instead of hardcoding IDs. Read-only calls work without a manager.
export function deepbookClient(
address: string,
balanceManagers?: { [key: string]: BalanceManager },
): DeepBookTestnetClient {
return new SuiGrpcClient({
network: 'testnet',
baseUrl: 'https://fullnode.testnet.sui.io:443',
}).$extend(deepbook({ address, balanceManagers }));
}

Set up a BalanceManager

Create one BalanceManager and reuse it across runs. It holds your deposited balances and is the account every order trades from. Creating a new manager each run leaves orphaned shared objects and confuses any step that lists your managers, so reuse an existing manager and create only when you have none.

Discover a manager the indexer knows for your address:

import type { DeepBookTestnetClient } from './client.js';

// Reuse an existing BalanceManager instead of minting a new one on every run.
// `getBalanceManagerIds` returns the managers the DeepBook indexer knows for an
// owner. It can return an empty list for a manager that has never traded or
// before the indexer catches up, so treat it as best-effort: also persist the ID
// you create (in config, a file, or an env var) and reuse that. Creating a new
// manager each run leaves orphaned shared objects and confuses any step that
// lists your managers.
export async function findManagerId(
client: DeepBookTestnetClient,
owner: string,
): Promise<string | undefined> {
const ids = await client.deepbook.getBalanceManagerIds(owner);
return ids[0];
}

If that returns nothing (you have never created one, or the indexer has not caught up), create and share one, then save the returned object ID so later runs reuse it:

import { Transaction } from '@mysten/sui/transactions';
import type { DeepBookTestnetClient } from './client.js';

// Create and share a BalanceManager. After execution, read its object ID from
// the transaction effects (the created object whose type ends in
// `BalanceManager`) and pass it to the client as a named manager to trade with.
export function createBalanceManager(client: DeepBookTestnetClient): Transaction {
const tx = new Transaction();
tx.add(client.deepbook.balanceManager.createAndShareBalanceManager());
return tx;
}

Observe: on creation, the effects contain a created shared object whose type ends in BalanceManager. Read its object ID from the effects, save it, and pass it to the client as a named manager.

Deposit assets

Deposit the asset you plan to trade. This workflow sells DEEP, so deposit DEEP.

import { Transaction } from '@mysten/sui/transactions';
import type { DeepBookTestnetClient } from './client.js';

// Deposit any accepted coin into the BalanceManager, addressed by SDK coin key
// (for example 'DEEP' or 'SUI'). The amount is in whole coins; the SDK scales it
// to the coin's decimals.
export function depositAsset(
client: DeepBookTestnetClient,
managerKey: string,
coinKey: string,
amount: number,
): Transaction {
const tx = new Transaction();
tx.add(client.deepbook.balanceManager.depositIntoManager(managerKey, coinKey, amount));
return tx;
}

Size the amount to your wallet balance. depositIntoManager sources coins with coinWithBalance, which fails at build time if you request more than you hold:

// Never deposit more than the wallet holds. `depositIntoManager` sources coins
// with coinWithBalance, which throws at build time otherwise:
// Insufficient balance of <coin> for owner <address>. Required: X, Available: Y
// Size the deposit to your actual balance, and leave a reserve when the coin is
// SUI so gas still has funds.
export async function safeDepositAmount(
client: DeepBookTestnetClient,
owner: string,
coinType: string,
decimals: number,
want: number,
reserve = 0,
): Promise<number> {
const b = await client.core.getBalance({ owner, coinType });
const have = Number(b?.balance?.balance ?? '0') / 10 ** decimals;
return Math.max(0, Math.min(want, have - reserve));
}

Observe: checkManagerBalance('MANAGER_1', 'DEEP') returns the deposited amount. Top up only when the manager holds less than the run needs, so re-runs do not keep depositing.

info

DEEP_SUI is a whitelisted pool, so it charges no trading fee and deducts no DEEP. The payWithDeep flag has no effect here. On a fee-paying pool, the pool deducts DEEP from your manager on each fill, sized by its floating DEEP price, so keep DEEP in the manager. See How the DEEP fee is sized and Pay fees in DEEP or the input token.

Place a limit order

Place a resting maker ask. POST_ONLY guarantees the order rests instead of taking: if it would cross the book, the pool does not place it, so you never pay a taker fee by accident. Price and quantity must respect the pool's tick size and lot size (DEEP_SUI: tick 0.00001, lot 1, minimum size 10 DEEP). The clientOrderId is your own tag for the order: the SDK types it as a string, but the contract encodes it as u64, so pass a numeric string such as a timestamp, not a label like order-1.

// Place a resting maker order. POST_ONLY guarantees it rests as liquidity: if it
// would cross the book it is not placed, so you never pay a taker fee by
// accident. `price` and `quantity` must respect the pool's tick and lot size.
export function placeMakerAsk(
client: DeepBookTestnetClient,
managerKey: string,
poolKey: string,
clientOrderId: string, // numeric string, encoded as u64
price: number,
quantity: number,
): Transaction {
const tx = new Transaction();
tx.add(
client.deepbook.deepBook.placeLimitOrder({
poolKey,
balanceManagerKey: managerKey,
clientOrderId,
price,
quantity,
isBid: false,
orderType: OrderType.POST_ONLY,
}),
);
return tx;
}

Observe: the transaction emits an OrderPlaced event, and the order rests on the book.

Read status and fills

Read the manager's open orders and account state. Open orders list resting order IDs, getOrder returns each order's quantity and filled_quantity, and account returns settled and locked balances. A fill moves funds into settled balances until you withdraw them.

import type { DeepBookTestnetClient } from './client.js';

// Read a manager's open orders and account state on a pool. `accountOpenOrders`
// lists resting order IDs. `getOrder` returns per-order fields including
// `quantity` and `filled_quantity`. `account` returns settled and locked
// balances: a fill moves funds into settled balances until you withdraw them.
export async function readOrders(
client: DeepBookTestnetClient,
poolKey: string,
managerKey: string,
) {
const openOrderIds = await client.deepbook.accountOpenOrders(poolKey, managerKey);
const orders = await Promise.all(openOrderIds.map((id) => client.deepbook.getOrder(poolKey, id)));
const account = await client.deepbook.account(poolKey, managerKey);
return { openOrderIds, orders, account };
}

Observe: the order appears in openOrderIds with filled_quantity 0, because it rests unfilled.

Cancel the order

Cancel the resting order by its ID. Cancelling returns the order's locked funds to the manager's settled balances.

import { Transaction } from '@mysten/sui/transactions';
import type { DeepBookTestnetClient } from './client.js';

// Cancel a resting order by its order ID (from `accountOpenOrders`). Cancelling
// returns the order's locked funds to the manager's settled balances, which you
// then withdraw.
export function cancelOrder(
client: DeepBookTestnetClient,
poolKey: string,
managerKey: string,
orderId: string,
): Transaction {
const tx = new Transaction();
tx.add(client.deepbook.deepBook.cancelOrder(poolKey, managerKey, orderId));
return tx;
}

Observe: the transaction emits an OrderCanceled event, and the order no longer appears in accountOpenOrders.

Fill your own order

To watch a fill settle, place a fresh maker ask, then place a taker bid that crosses it. Because both orders belong to the same manager, this is a self-trade.

caution

The SDK defaults selfMatchingOption to SELF_MATCHING_ALLOWED, so a taker order fills your own resting maker orders with no warning. To refuse trading against yourself, pass CANCEL_TAKER (abort the taker) or CANCEL_MAKER (cancel the resting maker). This step sets SELF_MATCHING_ALLOWED on purpose to fill the maker order you just placed.

// Place a taker order that crosses the book and fills. `selfMatchingOption` is
// set EXPLICITLY here: the SDK defaults to SELF_MATCHING_ALLOWED, so a taker
// order can fill your OWN resting maker order without any warning. Set
// CANCEL_TAKER or CANCEL_MAKER to refuse trading against yourself. This workflow
// allows it on purpose, to fill the maker order placed above and observe a fill.
export function placeTakerBid(
client: DeepBookTestnetClient,
managerKey: string,
poolKey: string,
clientOrderId: string, // numeric string, encoded as u64
price: number,
quantity: number,
): Transaction {
const tx = new Transaction();
tx.add(
client.deepbook.deepBook.placeLimitOrder({
poolKey,
balanceManagerKey: managerKey,
clientOrderId,
price,
quantity,
isBid: true,
orderType: OrderType.NO_RESTRICTION,
selfMatchingOption: SelfMatchingOptions.SELF_MATCHING_ALLOWED,
}),
);
return tx;
}

Observe: after the taker order, the maker order leaves accountOpenOrders because it filled fully, and the account's taker_volume and maker_volume both increase by the traded quantity. Because this is a self-trade at one price, the settled balances net to about zero (you sold and bought the same amount), so the fill shows up in the volumes rather than the balances.

Withdraw

Funds you deposit or earn live in the manager, a shared object, until you withdraw them. Withdraw the settled balances back to your wallet.

import { Transaction } from '@mysten/sui/transactions';
import type { DeepBookTestnetClient } from './client.js';

// Withdraw a coin's full settled balance from the manager back to `recipient`.
// After a fill, settled balances hold your proceeds until you withdraw them.
export function withdrawAll(
client: DeepBookTestnetClient,
managerKey: string,
coinKey: string,
recipient: string,
): Transaction {
const tx = new Transaction();
tx.add(client.deepbook.balanceManager.withdrawAllFromManager(managerKey, coinKey, recipient));
return tx;
}

Observe: your wallet balance increases and the manager's balance for that coin returns to 0.

Find stranded funds

If a later deposit fails for insufficient funds even though you funded the key, the funds are usually sitting in another manager, not lost, because each manager is a shared object that keeps what you deposit. List the managers you have created (persist each ID when you create it, or scan your creation transactions with suix_queryTransactionBlocks filtered by FromAddress) and check their balances:

import { deepbookClient } from './client.js';

// Report where a key's funds live across its BalanceManagers. Managers are
// shared objects, so anything you deposit stays in them until you withdraw.
// When a deposit cannot be afforded, the funds are usually stranded in another
// manager, not lost: pass the manager IDs you have created (persist each one)
// and check their balances before erroring.
export async function reportManagers(
owner: string,
managerIds: string[],
coinKeys: string[],
): Promise<void> {
for (const id of managerIds) {
const c = deepbookClient(owner, { M: { address: id } });
const balances = await Promise.all(
coinKeys.map((k) => c.deepbook.checkManagerBalance('M', k).then((b) => `${k}=${b.balance}`)),
);
console.log(`${id}: ${balances.join(' ')}`);
}
}

Withdraw each non-empty manager with withdrawAllFromManager, then reuse one manager going forward so funds stop fragmenting across orphans.

Troubleshooting

For environment and funding failures (faucet rate limits, stale RPC URL, CLI version skew, gas-budget sizing, waiting for finality, empty-book zero-fill), see the Fees and Funding troubleshooting. Order-specific points:

  • Order below minimum or off-tick: an order smaller than the pool's minimum size, or priced off its tick size, is rejected. Read the pool's minSize, lotSize, and tickSize with poolBookParams and round to them.
  • POST_ONLY returns nothing: a POST_ONLY order that would cross the book is not placed, and returns no order rather than aborting. Price it away from the opposite side of the book.
  • A taker order filled your own resting order: the SDK allows self-matching by default. Set selfMatchingOption to CANCEL_TAKER or CANCEL_MAKER if that is not what you want.
  • Insufficient balance of <coin> ... Required X, Available Y: the friendly build-time error from coinWithBalance when a deposit or swap requests more of a coin than the wallet holds. Size the amount to your balance. Contrast it with InsufficientCoinBalance, the raw simulation error you get when the amounts plus the gas budget together exceed your balance.
  • Every run mints a new manager: you are not reusing an existing BalanceManager. Discover or persist the ID and create only when absent, as in Set up a BalanceManager.
  • Cannot convert <value> to a BigInt: clientOrderId is encoded as u64. The SDK types it as a string, but a non-numeric value fails to encode. Use a numeric string, such as a timestamp.