Composing DeepBook Transactions
A programmable transaction block (PTB) runs a sequence of commands as one atomic transaction: either every command applies or none does, and one command's result can feed the next. That makes DeepBook operations composable. The samples below build three compositions and run compile-checked against @mysten/deepbook-v3 and @mysten/sui, referencing pools and coins by SDK key rather than hardcoding onchain IDs. For the reusable CLI and SDK templates behind them, see the PTB and CLI cookbook.
The third composition supplies to a margin lending pool. Lending and margin trading carry risks the spot path does not. Read Margin Risks before supplying or borrowing.
Two guarantees do the work in every composition:
- Atomicity. If any command in the block aborts, the whole block rolls back. A composition never half-applies.
- Hot potato. A value with no
store,copy,drop, orkeyability cannot be kept, dropped, or transferred, so you have to consume it before the block ends. DeepBook's flash loan returns one, which is what forces repayment in the same transaction.
Fund and order atomically
Deposit into a BalanceManager and place an order in the same block, so both settle together. The cookbook has the CLI form; the compile-checked SDK version:
// Deposit into the BalanceManager and place an order in the SAME transaction, so
// both settle atomically: if the order placement aborts, the deposit rolls back
// with it, and you never end up funded but orderless or vice versa. Each
// `tx.add` appends a command to the one programmable transaction block.
export function fundAndOrder(
client: DeepBookTestnetClient,
managerKey: string,
depositCoinKey: string,
depositAmount: number,
poolKey: string,
clientOrderId: string, // numeric string, encoded as u64
price: number,
quantity: number,
): Transaction {
const tx = new Transaction();
tx.add(client.deepbook.balanceManager.depositIntoManager(managerKey, depositCoinKey, depositAmount));
tx.add(
client.deepbook.deepBook.placeLimitOrder({
poolKey,
balanceManagerKey: managerKey,
clientOrderId,
price,
quantity,
isBid: false,
orderType: OrderType.POST_ONLY,
}),
);
return tx;
}
Observe: the deposit and the resting order appear in the same transaction's effects. If the pool rejects the order (for example off-tick), the deposit rolls back with it.
Flash loan round trip
Borrow, use, and repay in one block. borrowBaseAsset returns the borrowed coin and the FlashLoan hot potato; returnBaseAsset consumes it. The cookbook and Flash Loans SDK cover the quote variants.
// Borrow, use, and repay a flash loan in one transaction. `borrowBaseAsset`
// returns the borrowed coin and a `FlashLoan` hot potato: a struct with no
// abilities that cannot be stored, dropped, or transferred, so the only way to
// finish the transaction is to pass it back to `returnBaseAsset`. Forget to
// return it and the whole block aborts. The borrowed coin flows through your
// logic (the "use" step) and the repaid coin covers the borrow; here the use
// step is a no-op so the example is self-contained, but this is exactly where a
// call into another protocol would go, under the same all-or-nothing rule.
export function flashLoanRoundTrip(
client: DeepBookTestnetClient,
poolKey: string,
borrowAmount: number,
recipient: string,
): Transaction {
const tx = new Transaction();
const [borrowed, flashLoan] = tx.add(
client.deepbook.flashLoans.borrowBaseAsset(poolKey, borrowAmount),
);
// ... use `borrowed` here: swap it, arbitrage, or call another protocol ...
const remainder = tx.add(
client.deepbook.flashLoans.returnBaseAsset(poolKey, borrowAmount, borrowed, flashLoan),
);
tx.transferObjects([remainder], recipient);
return tx;
}
Observe: the block commits only if you return the hot potato. The no-op use step is where a call into another protocol goes: borrow from DeepBook, act on the borrowed funds through any Move call, and repay before the block ends, all under the same atomicity rule. That is the generalization point for cross-protocol composition.
Lend, trade, and stake in one transaction
Deploy capital three ways in one block: supply to a margin pool, rest a maker order, and stake DEEP for governance rebates. The legs are independent, but composing them makes them atomic.
Supplying needs a SupplierCap, a reusable owned object. Mint it once, persist its ID, and reuse it; minting a fresh cap each run fragments your supply position, so gate creation on an existing ID (the same reuse pattern as a BalanceManager).
// A SupplierCap authorizes supplying to (and withdrawing from) margin pools and
// tracks your supply position. It is a reusable owned object: mint it once, read
// its ID from the created objects in the effects, persist it, and reuse it for
// every later supply. Minting a fresh cap each time fragments your supply
// position across caps, so gate creation on an existing persisted ID.
export function createSupplierCap(client: DeepBookMarginClient, owner: string): Transaction {
const tx = new Transaction();
const cap = tx.add(client.deepbook.marginPool.mintSupplierCap());
tx.transferObjects([cap], owner);
return tx;
}
With the cap in hand, compose the three legs. Before running it, read your preconditions and size the legs to them: the lend leg pulls its coin from the wallet (keep a SUI gas reserve), and the trade and stake legs both draw DEEP from the BalanceManager, so it must hold enough for the order and the stake together.
// Deploy capital three ways in one transaction: lend to a margin pool, place a
// maker order on the book, and stake DEEP for governance rebates. The three
// legs are independent, but composing them in one programmable transaction block
// makes them atomic: if any leg aborts (for example the order is off-tick or the
// stake amount is zero), none of them apply, so you never lend without also
// getting the order and stake you intended.
//
// The lend leg uses a reusable SupplierCap and pulls its coin from the wallet.
// The trade and stake legs both act on a spot BalanceManager, so keep enough
// DEEP in the manager to cover the order and the stake together.
export interface LendTradeStakeParams {
balanceManagerKey: string;
supplierCapId: string;
lendCoinKey: string;
lendAmount: number;
poolKey: string;
clientOrderId: string; // numeric string, encoded as u64
price: number;
quantity: number;
stakeAmount: number;
}
export function lendTradeStake(
client: DeepBookMarginClient,
p: LendTradeStakeParams,
): Transaction {
const tx = new Transaction();
// Leg 1 (lend): supply to the margin pool with the reusable SupplierCap.
tx.add(
client.deepbook.marginPool.supplyToMarginPool(p.lendCoinKey, tx.object(p.supplierCapId), p.lendAmount),
);
// Leg 2 (trade): rest a maker order. POST_ONLY so it never crosses and takes.
tx.add(
client.deepbook.deepBook.placeLimitOrder({
poolKey: p.poolKey,
balanceManagerKey: p.balanceManagerKey,
clientOrderId: p.clientOrderId,
price: p.price,
quantity: p.quantity,
isBid: false,
orderType: OrderType.POST_ONLY,
}),
);
// Leg 3 (stake): stake DEEP into the pool's governance for fee rebates.
tx.add(client.deepbook.governance.stake(p.poolKey, p.balanceManagerKey, p.stakeAmount));
return tx;
}
Observe: one transaction records the supply to the margin pool, the resting order, and the stake. getUserSupplyAmount reflects the new supply, accountOpenOrders lists the order, and the staked DEEP moves into the pool's governance for the next epoch. If any leg aborts, none of the three apply.
When a composition aborts
A composed block fails as a unit, so a late command's failure discards the earlier commands' work. The common causes, several of which surface the same way outside compositions:
- You do not return the hot potato. A borrowed
FlashLoanthat you never pass to a return call cannot be dropped or stored, so the block aborts. This is the mechanism that makes flash loans safe, not an edge case. - A later leg reverts, discarding an earlier one. In the fund-and-order composition, depositing less than the order needs to lock aborts the order leg with
balance_managerabort code 3 (EBalanceManagerBalanceTooLow), and the deposit that ran first rolls back with it, so nothing settles. An off-tick or below-minimum order, or a zero-amount stake (the contract requires a positive stake), aborts the same way. Size and validate each leg, and make a funding leg cover what a later leg consumes, before composing. - A build-time balance shortfall. When a fund or lend leg sources coins with
coinWithBalance, requesting more than the wallet holds throws before submission:Insufficient balance of <coin> for owner <address>. Required: X, Available: Y. Size each leg to the actual balance. - A stale object version. Building a second transaction against an object a prior transaction just changed, without waiting for finality, fails with a version-unavailable error. Within a single PTB this does not arise, because the commands share one execution; across chained transactions, wait for each to finalize before building the next.