PTB cookbook
You can send to multiple recipients, sponsor a transaction, borrow and return a kiosk item, combine sponsorship with a kiosk purchase, select coins from mixed sources, and chain Move call results in a single programmable transaction block (PTB). Each pattern below has a copy-paste TypeScript recipe, and each recipe is a complete, self-contained example.
- Prerequisites
- Install the Sui TypeScript SDK by running
npm install @mysten/sui. - Configure a
SuiGrpcClientpointed at the network you are targeting.
For PTB fundamentals, see Building Transactions. To send one from your own app, see Sending PTBs with the TypeScript SDK.
How do I send to multiple recipients in one PTB?
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 the transaction creates no leftover objects. - All transfers succeed or fail atomically.
Sponsored transaction
A sponsor pays gas on behalf of a user:
- The user builds the transaction logic with
onlyTransactionKind: true, which strips gas information so the sponsor can provide it. - The sponsor sets
setGasOwnerto their own address and provides their gas coins. - Both the user and the sponsor 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 information so the sponsor can provide it.- The sponsor sets
setGasOwnerto their own address and provides their gas coins. - You need both signatures to execute the transaction.
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 });
How do I do a kiosk borrow and return in one transaction?
Borrow an item from a kiosk, perform an operation on it, and return it in one PTB. The borrow_val and 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 the same PTB must consume.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.
How do I combine sponsored transactions with kiosk operations?
Combine sponsorship with a kiosk purchase: the buyer selects and pays for the item, while a sponsor covers the gas fee.
This recipe builds on two patterns from this page: the sponsored transaction flow, where the sponsor provides gas and both parties sign, and withdrawing from an address balance, which funds the buyer's payment.
// === 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()andcoin::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.
How do I select coins from address balances in a PTB?
Sui supports both coin objects and address balances. You can explicitly choose which source to use in a PTB.
To choose a source:
- Use
tx.balance()when you want to deposit into a recipient's address balance and let the SDK select the funding source automatically. - Use
tx.splitCoinswith a specific coin object when you need precise control over which coin is spent. - Use
tx.withdrawal()withcoin::redeem_fundswhen you need aCoin<T>to pass to Move functions.
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.
How do I chain Move call results in a PTB?
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 you can pass 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.