PTB cookbook
This page provides copy-paste TypeScript recipes for common PTB patterns. Each recipe is a complete, self-contained example.
- Prerequisites
- Install
@mysten/suiby runningnpm install @mysten/sui. - Configure a
SuiClientorSuiGrpcClient.
For PTB fundamentals, see Building Transactions. For project setup, see TypeScript SDK PTB Template.
Multi-recipient transfer
Split a coin and transfer portions to multiple recipients in a single atomic transaction. This is more efficient and cheaper than sending individual transactions.
const tx = new Transaction();
const recipients = [
{ address: '0xAlice...', amount: 1_000_000_000n }, // 1 SUI
{ address: '0xBob...', amount: 500_000_000n }, // 0.5 SUI
{ address: '0xCarol...', amount: 250_000_000n }, // 0.25 SUI
];
// Split the gas coin into one coin per recipient.
const coins = tx.splitCoins(
tx.gas,
recipients.map((r) => tx.pure('u64', r.amount)),
);
// Transfer each split coin to its recipient.
recipients.forEach((r, i) => {
tx.transferObjects([coins[i]], tx.pure('address', r.address));
});
await client.signAndExecuteTransaction({ signer: keypair, transaction: tx });
Key points:
tx.splitCoins(tx.gas, amounts)creates multiple coins from the gas coin in one command.- Each
tx.transferObjectsconsumes one split coin, so no leftover objects are created. - All transfers succeed or fail atomically.
Sponsored transaction
A sponsor pays gas on behalf of a user. The user builds the transaction logic, the sponsor provides gas, and both sign before submission.
// === User side ===
// Build the transaction kind (no gas info yet).
const tx = new Transaction();
tx.moveCall({
target: '0xPACKAGE::module::do_something',
arguments: [tx.object('0xSomeObject')],
});
// Serialize only the transaction kind bytes for the sponsor.
const kindBytes = await tx.build({ client, onlyTransactionKind: true });
// === Sponsor side ===
// Reconstruct the transaction from the kind bytes.
const sponsoredTx = Transaction.fromKind(kindBytes);
// Set the user as the sender, sponsor as the gas owner.
sponsoredTx.setSender(userAddress);
sponsoredTx.setGasOwner(sponsorAddress);
sponsoredTx.setGasPayment(sponsorGasCoins); // Sponsor's coin objects
// Build the full transaction bytes.
const txBytes = await sponsoredTx.build({ client });
// === Both sign ===
// The sponsor signs first.
const sponsorSig = await sponsorKeypair.signTransaction(txBytes);
// The user signs second.
const userSig = await userKeypair.signTransaction(txBytes);
// === Submit ===
const result = await client.executeTransaction({
transaction: txBytes,
signatures: [userSig.signature, sponsorSig.signature],
});
const sponsoredTx2 = Transaction.fromKind(kindBytes);
sponsoredTx2.setSender(userAddress);
sponsoredTx2.setGasOwner(sponsorAddress);
// Empty gas payment array tells the protocol to deduct gas from the
// sponsor's SUI address balance. The SDK sets the ValidDuring expiration
// and nonce automatically when you build with a connected client.
sponsoredTx2.setGasPayment([]);
// Building with a connected client resolves the expiration and nonce.
const txBytes2 = await sponsoredTx2.build({ client });
Key points:
onlyTransactionKind: truestrips gas info so the sponsor can provide it.- The sponsor sets
setGasOwnerto their own address and provides their gas coins. - Both signatures are required when executing.
For the conceptual model, see Sponsored Transactions.
Sponsored transaction with address balance gas
The sponsor can also pay gas from their SUI address balance instead of providing specific coin objects. Address-balance gas requires additional configuration (a ValidDuring expiration for replay protection and a nonce). Read the full requirements for address-balance gas payment before using this pattern in production.
const sponsoredTx2 = Transaction.fromKind(kindBytes);
sponsoredTx2.setSender(userAddress);
sponsoredTx2.setGasOwner(sponsorAddress);
// Empty gas payment array tells the protocol to deduct gas from the
// sponsor's SUI address balance. The SDK sets the ValidDuring expiration
// and nonce automatically when you build with a connected client.
sponsoredTx2.setGasPayment([]);
// Building with a connected client resolves the expiration and nonce.
const txBytes2 = await sponsoredTx2.build({ client });
Kiosk borrow, mutate, and return
Borrow an item from a kiosk, perform an operation on it, and return it in one PTB. The borrow_val / return_val pattern uses a hot-potato Borrow object that enforces the return within the same transaction.
const tx = new Transaction();
const itemType = '0xPACKAGE::module::MyItem';
const kioskId = '0xKioskObjectId';
const kioskCapId = '0xKioskOwnerCapId';
const itemId = '0xItemObjectId';
// Borrow the item. Returns [item, borrowHotPotato].
const [item, borrow] = tx.moveCall({
target: '0x2::kiosk::borrow_val',
typeArguments: [itemType],
arguments: [tx.object(kioskId), tx.object(kioskCapId), tx.pure('address', itemId)],
});
// Mutate the borrowed item (call your custom function).
tx.moveCall({
target: '0xPACKAGE::module::enhance_item',
arguments: [item],
});
// Return the item to the kiosk (consumes the hot potato).
tx.moveCall({
target: '0x2::kiosk::return_val',
typeArguments: [itemType],
arguments: [tx.object(kioskId), item, borrow],
});
await client.signAndExecuteTransaction({ signer: keypair, transaction: tx });
Key points:
borrow_valreturns the item and aBorrowhot potato that must be consumed in the same PTB.return_valconsumes theBorrowand puts the item back. If you forget to call it, the transaction fails.- Use
borrow_valwhen you need to pass the item by value to another Move function. For in-place mutations, considerborrow_mutinstead.
For more kiosk operations, see Sui Kiosk.
Sponsored kiosk purchase
Combine sponsorship with a kiosk purchase: the buyer selects and pays for the item, while a sponsor covers the gas fee.
// === Buyer side ===
const tx = new Transaction();
const itemType = '0xPACKAGE::module::MyItem';
const kioskId = '0xSellerKioskId';
const itemId = '0xItemObjectId';
const price = 5_000_000_000n; // 5 SUI
const buyerKioskId = '0xBuyerKioskId';
const transferPolicyId = '0xTransferPolicyId';
// Withdraw from the buyer's address balance, then redeem to get a Coin<SUI>.
// Do NOT use tx.gas — after sponsorship, the gas coin belongs to the sponsor.
const [paymentCoin] = tx.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.withdrawal({ amount: price })],
});
// Purchase the item from the seller's kiosk.
const [item, transferRequest] = tx.moveCall({
target: '0x2::kiosk::purchase',
typeArguments: [itemType],
arguments: [tx.object(kioskId), tx.pure('address', itemId), paymentCoin],
});
// Resolve the transfer policy (example: a rule that just needs confirmation).
tx.moveCall({
target: '0xPOLICY_PACKAGE::my_rule::prove',
typeArguments: [itemType],
arguments: [tx.object(transferPolicyId), transferRequest],
});
// Confirm the transfer policy is satisfied.
tx.moveCall({
target: '0x2::transfer_policy::confirm_request',
typeArguments: [itemType],
arguments: [tx.object(transferPolicyId), transferRequest],
});
// Place the item in the buyer's kiosk.
tx.moveCall({
target: '0x2::kiosk::place',
typeArguments: [itemType],
arguments: [tx.object(buyerKioskId), tx.object('0xBuyerKioskCap'), item],
});
// Serialize kind bytes for the sponsor.
const kindBytes = await tx.build({ client, onlyTransactionKind: true });
// === Sponsor side ===
const sponsoredTx = Transaction.fromKind(kindBytes);
sponsoredTx.setSender(buyerAddress);
sponsoredTx.setGasOwner(sponsorAddress);
sponsoredTx.setGasPayment(sponsorGasCoins);
// Both sign and submit (same pattern as the sponsored transaction recipe).
Key points:
- The buyer's payment comes from their address balance through
tx.withdrawal()+coin::redeem_funds, not fromtx.gas. In a sponsored transaction,tx.gasbelongs to the sponsor. - Transfer policy resolution happens within the same PTB, ensuring atomicity.
- The sponsor only covers gas. The purchase price comes from the buyer's address balance.
Coin selection from mixed sources
Sui supports both coin objects and address balances. You can explicitly choose which source to use in a PTB.
Send to a recipient's address balance
Use tx.balance() to create a Balance<T> input (the SDK selects the funding source automatically), then pass it to balance::send_funds to deposit into the recipient's address balance:
const tx = new Transaction();
// Send 1 SUI to the recipient's address balance. The SDK selects the funding source.
tx.moveCall({
target: '0x2::balance::send_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx.balance({ balance: 1_000_000_000n }), tx.pure.address(recipientAddress)],
});
From specific coin objects
Use tx.splitCoins with a specific coin object when you need precise control:
const tx2 = new Transaction();
// Split from a specific coin object (not the gas coin).
const [coin] = tx2.splitCoins(tx2.object('0xSpecificCoinId'), [tx2.pure('u64', 500_000_000n)]);
tx2.transferObjects([coin], tx2.pure.address(recipientAddress));
Withdraw and use in Move calls
Use tx.withdrawal() to create a withdrawal input, then redeem it through coin::redeem_funds to get a Coin<T> you can pass to Move functions:
const tx3 = new Transaction();
// Withdraw from address balance and redeem to get a Coin<SUI>.
const [redeemed] = tx3.moveCall({
target: '0x2::coin::redeem_funds',
typeArguments: ['0x2::sui::SUI'],
arguments: [tx3.withdrawal({ amount: 1_000_000_000 })],
});
// Pass the coin to a Move function that expects Coin<SUI>.
tx3.moveCall({
target: '0xPACKAGE::module::deposit',
arguments: [redeemed],
});
For more on address balances, see Using Address Balances.
Batch Move calls with result chaining
Each PTB command returns a reference to its result, which you can pass as input to subsequent commands. This lets you compose complex multi-step operations in a single atomic transaction.
const tx = new Transaction();
// Step 1: Create a new game character.
const [character] = tx.moveCall({
target: '0xGAME::character::create',
arguments: [tx.pure('string', 'Hero')],
});
// Step 2: Mint a sword and equip it to the character.
const [sword] = tx.moveCall({
target: '0xGAME::items::mint_sword',
arguments: [tx.pure('u64', 100)], // attack power
});
tx.moveCall({
target: '0xGAME::character::equip',
arguments: [character, sword],
});
// Step 3: Transfer the character to the player.
tx.transferObjects([character], tx.pure.address(playerAddress));
await client.signAndExecuteTransaction({ signer: keypair, transaction: tx });
Key points:
- Results from
tx.moveCallareTransactionArgumentreferences that can be passed to any subsequent command. - If a Move function returns multiple values, destructure them:
const [a, b] = tx.moveCall(...). - All commands execute in order within the same PTB. If any command fails, the entire transaction reverts.
- You can mix
moveCall,splitCoins,transferObjects, and other commands freely.