Skip to main content

What is a PTB?

A programmable transaction block (PTB) is a group of commands that complete a transaction on Sui.

Transactions on Sui consist of groups of commands that execute on inputs to define the result of the transaction. Referred to as programmable transaction blocks (PTBs), these groups of commands define all user transactions on Sui. PTBs allow you to call multiple Move functions, manage your objects, and manage your coins in a single transaction without publishing a new Move package. Designed with automation and transaction builders in mind, PTBs are a lightweight and flexible way of generating transactions.

However, PTBs do not support more intricate programming patterns, such as loops. In such cases, you must publish a new Move package.

The individual transaction commands within a PTB execute in order. You can use the results from one transaction command in any subsequent transaction command within the PTB. The runtime applies the effects of each transaction command in the block, such as object modifications or transfers, atomically at the end of the transaction. If one transaction command fails, the entire block fails and none of the effects from the commands apply.

A PTB can perform up to 1,024 unique operations in a single execution, whereas transactions on traditional blockchains would require 1,024 individual executions to accomplish the same result. This structure also results in cheaper gas fees. The cost of facilitating individual transactions is always more than the cost of those same transactions blocked together in a PTB, resulting in cheaper gas fees.

What are the components of a PTB?​

There are two parts of a PTB that are relevant to execution semantics. Execution might reference other transaction information, such as the transaction sender or the gas limit, but that information is out of scope. The structure of a PTB is:

{
inputs: [Input],
commands: [Command],
}

Looking closer at the two main components:

  • The inputs value is a vector of arguments, [Input]. These arguments are either objects or pure values that you can use in the commands. The sender either owns the objects, or the objects are shared or immutable objects. The pure values represent basic Move values, such as u64 or String values, which you can construct purely from their bytes. For historical reasons, Input is CallArg in the Rust implementation.

  • The commands value is a vector of commands, [Command]. The following list shows the possible commands as TypeScript SDK builder methods (tx.splitCoins and so on). The execution sections later on this page refer to the same commands by their underlying names (SplitCoins, MergeCoins, TransferObjects, MoveCall, MakeMoveVec, Publish, Upgrade). The possible commands are:

  • tx.splitCoins(coin, amounts): Creates new coins with the defined amounts, split from the provided coin. Returns the coins so that you can use them in subsequent transactions.

    • Example: tx.splitCoins(tx.gas, [tx.pure.u64(100), tx.pure.u64(200)])
  • tx.mergeCoins(destinationCoin, sourceCoins): Merges the sourceCoins into the destinationCoin.

    • Example: tx.mergeCoins(tx.object(coin1), [tx.object(coin2), tx.object(coin3)])
  • tx.transferObjects(objects, address): Transfers a list of objects to the specified address.

    • Example: tx.transferObjects([tx.object(thing1), tx.object(thing2)], tx.pure.address(myAddress))
  • tx.moveCall({ target, arguments, typeArguments }): Executes a Move call. Returns whatever the Sui Move call returns.

    • Example: tx.moveCall({ target: '0x2::devnet_nft::mint', arguments: [tx.pure.string(name), tx.pure.string(description), tx.pure.string(image)] })
  • tx.makeMoveVec({ type, elements }): Constructs a vector of objects that you can pass into a moveCall. You need this command because there is no other way to define a vector as an input.

    • Example: tx.makeMoveVec({ elements: [tx.object(id1), tx.object(id2)] })
  • tx.publish(modules, dependencies): Publishes a Move package. Returns the upgrade capability object.

  • tx.upgrade(modules, dependencies, packageId: EXAMPLE_PACKAGE_ID, ticket): Upgrades an existing package. The runtime does not call init functions for upgraded modules.

Learn more about each PTB command.

Inputs and results​

Inputs are the values that you provide to the PTB, and results are the values that the PTB commands produce. The inputs are either objects or basic Move values, and the results are arbitrary Move values, including objects. Inputs and results form the data flow through a PTB's execution.

You can think of them as populating an array of values. For inputs, there is a single array, but for results, there is an array for each individual transaction command, creating a 2D-array of result values. You can access these values by borrowing, mutably or immutably, by copying, if the type permits, or by moving, which takes the value out of the array without re-indexing.

Learn more about inputs and results.

Argument structure and usage​

Commands take Argument values that specify which input or result to use. The runtime infers whether to pass the argument by reference or by value based on the command's expected type.

The Argument enum has 4 variants, shown here in the notation of the Rust implementation (the same notation the execution examples on this page use) rather than as TypeScript SDK calls:

  • Input(u16): References an input by its index. For example, with inputs [Object1, Object2, Pure1, Object3], use Input(0) for Object1 and Input(2) for Pure1.

  • GasCoin: References the SUI coin used to pay for gas. When using address balance gas payment (empty gas payment list), GasCoin references a synthetic coin created from the sender or sponsor address balance. This is separate from other inputs because it has special restrictions:

    • &mut or & usage anywhere.
    • Only by value through TransferObjects or sui::coin::send_funds
      • If you need an owned Coin<SUI> from the GasCoin, first use SplitCoins.
  • NestedResult(u16, u16): References a result from a previous command. The first index specifies the command, the second specifies which result from that command. For example, if command 1 returns [Value1, Value2], use NestedResult(1, 0) for Value1 and NestedResult(1, 1) for Value2.

  • Result(u16): For a command at index i, Result(i) is shorthand for NestedResult(i, 0), but only valid when the command at index i returns exactly 1 result. Use NestedResult for commands that return 0 or multiple results.

How does a PTB execute?​

When a PTB executes, the runtime populates the input vector with the input objects or pure value bytes. The runtime then executes the transaction commands in order and stores the results in the result vector. Finally, the runtime applies the effects of the transaction atomically.

Start of execution​

At the beginning of execution, the PTB runtime loads the input objects into the input array. The network already verifies the objects, checking rules like existence and valid ownership. The runtime also loads the pure value bytes into the array but does not validate them until usage.

The most important consideration at this stage is the effects on the gas coin. At the beginning of execution, the system withdraws the maximum gas budget (in terms of SUI) from the gas coin. The system returns any unused gas to the gas coin at the end of execution, even if the coin has changed owners.

Object consumption​

You must either consume all objects that Move commands create or return (destroy them, transfer them, or use them in another command) or explicitly drop them if the type has the drop ability.

In a PTB, if you create an object through a Move command and do not destroy, transfer, or use it in a subsequent command, the transaction fails with an error.

What is checked before a PTB runs?​

When you sign a transaction, the network performs the following validations on specific commands:

  • SplitCoins and MergeCoins: Verifies that the argument arrays (AmountArgs and ToMergeArgs) are non-empty.

  • MakeMoveVec: Verifies that the command specifies the type for an empty vector of Args.

  • Publish: Verifies that the ModuleBytes are not empty.

Argument usage rules​

You can use each argument by reference or by value. The usage depends on the type of the argument and the type signature of the command:

  • If the signature expects an &mut T, the runtime checks the argument has type T and then mutably borrows it.

  • If the signature expects an &T, the runtime checks the argument has type T and then immutably borrows it.

  • If the signature expects a T, the runtime verifies the argument has type T, then copies the value if T: copy or moves it otherwise. The runtime always moves objects because sui::object::UID does not have the copy ability.

The transaction fails if you use an argument in any form after the runtime moves it. There is no way to restore an argument to its position (its input or result index) after the runtime moves it.

If the runtime copies an argument that does not have the drop ability, it infers the last usage to be a move. As a result, if an argument has copy and does not have drop, the last usage must be by value. Otherwise, the transaction fails because a value without drop remains unused.

Borrowing rules​

Borrowing follows additional rules to ensure safe reference usage:

  • Mutable borrow: No other borrows can be outstanding. An overlapping mutable borrow could create dangling references.

  • Immutable borrow: No mutable borrows can be outstanding. Multiple immutable borrows can coexist.

  • Move: No borrows can be outstanding. Moving a borrowed value invalidates existing references.

  • Copy: Allowed regardless of outstanding borrows.

Special object handling​

Object inputs have the type T of the underlying object. ObjectArg::Receiving inputs are the exception and have type sui::transfer::Receiving<T>. This wrapper indicates that another object, not the sender, owns the object. Call sui::transfer::receive with the parent object to unwrap it and prove ownership.

Shared objects have restrictions on by value usage to ensure they remain shared or are deleted by the end of the transaction:

  • You cannot use read-only shared objects (marked as not mutable) by value.

  • You cannot transfer or freeze shared objects. These operations succeed during execution but cause the transaction to fail at the end.

  • You can wrap shared objects or convert them to dynamic fields during execution, but you must re-share or delete them before the transaction completes.

Move call rules​

PTBs can call any public function and any entry function, whether private (entry fun f()), or public(package) (public(package) entry fun f()). PTBs cannot call non-entry private and public(package) functions. In this way, there is no reason to add entry to a public function.

info

Previously, entry functions had signature restrictions that limited their parameter and return types compared to public functions. These restrictions no longer apply. entry functions can now have the same signature as any public function.

Return types: Move calls cannot return references (&T or &mut T).

Private generics: Some framework functions have type parameters that you can only instantiate with types defined in the same module. Because PTBs are not modules, they cannot supply these types and therefore cannot call these functions directly. For example, certain sui::transfer functions like transfer and share_object require a type defined in the calling module, so a PTB cannot call them. Instead, use the public_transfer and public_share_object variants.

TxContext handling: The runtime automatically injects TxContext parameters (&TxContext or &mut TxContext); callers do not supply them. TxContext can appear at any position in the parameter list, and a function can have multiple TxContext parameters as long as they are immutable (&TxContext). These parameters do not count toward the user-supplied argument count for indexing purposes.

Non-public entry function restrictions​

A non-public entry function is a function declared with entry that is not public. It is either private (entry fun f()) or public(package) (public(package) entry fun f()). PTBs can call these functions directly, but other packages cannot.

Non-public entry functions have a single restriction: their arguments cannot be in a hot clique (see below) when the PTB calls the function. In short, a clique is a group of values that commands have used together, and it is hot while it is entangled with hot potato values that the PTB has not yet consumed. This restriction ensures that arguments to non-public entry functions are not entangled with outstanding hot potato values that could force behavior after the function executes.

Hot potato values

A value is a hot potato if its type has neither the drop nor the store ability. You must consume (move by value) hot potato values before the transaction completes; you cannot silently drop or store them.

Cliques

The system tracks which values are entangled using cliques. As described above, the goal is to prevent hot potato values from forcing behavior outside of the call to the non-public entry function. The cliques provide a modeling of this entanglement by keeping a count of how many hot potato values are live and which values they have interacted with:

  • Each PTB input starts in its own clique with a hot count of 0.
  • When a command uses values together as arguments, their cliques merge. The hot counts add together.
  • Hot potato return values from a command increment the merged clique's hot count.
  • Moving (consuming) a hot potato decrements its clique's hot count.
  • Before a non-public entry call, the merged clique of its arguments must have a hot count of 0.

A non-public entry function can consume hot potato values, but they must be the last hot values in their clique. The hot count check happens after argument consumption but before function verification.

Shared objects consumed by value

Consuming a shared object by value permanently marks its clique as always hot. Because you cannot wrap shared objects (you must re-share or delete them), the system treats consuming one by value similarly to a hot potato that you can never resolve. A non-public entry function can receive a shared object by value directly, but it cannot receive a value whose clique previously interacted with a shared object consumed by value.

Examples

Consider the following module:

module ex::m;

public struct HotPotato()

public fun hot<T>(x: &mut Coin<T>): HotPotato { ... }

entry fun spend<T>(x: &mut Coin<T>) { ... }

public fun cool(h: HotPotato) { ... }

In this invalid PTB, the HotPotato from command 0 is still alive in the same clique as Input(0) when command 1 calls spend:

// Invalid PTB
// Input 0: Coin<SUI>
// cliques: { Input(0) } => 0
0: ex::m::hot(Input(0));
// cliques: { Input(0), Result(0) } => 1
1: ex::m::spend(Input(0)); // INVALID: Input(0)'s clique has count > 0
2: ex::m::cool(Result(0));

However, if you consume the hot potato before calling spend, the clique's count drops to 0 and the call succeeds:

// Valid PTB
// Input 0: Coin<SUI>
// cliques: { Input(0) } => 0
0: ex::m::hot(Input(0));
// cliques: { Input(0), Result(0) } => 1
1: ex::m::cool(Result(0));
// cliques: { Input(0) } => 0
2: ex::m::spend(Input(0)); // Valid: Input(0)'s clique has count 0

In a flash loan scenario, entanglement extends transitively. Even if a value was not directly involved in the loan, being in the same clique as the loan's hot potato makes it ineligible for a non-public entry call:

module flash::loan;

public struct Loan { amount: u64 }

public fun issue(bank: &mut Bank, amount: u64): (Balance<SUI>, Loan) { ... }

public fun repay(bank: &mut Bank, loan: Loan, repayment: Balance<SUI>) { ... }
// Invalid PTB
// Input 0: flash::loan::Bank, Input 1: u64
// cliques: { Input(0) } => 0, { Input(1) } => 0
0: flash::loan::issue(Input(0), Input(1))
// cliques: { Input(0), Input(1), NestedResult(0,0), NestedResult(0,1) } => 1
1: sui::coin::from_balance(NestedResult(0,0));
// cliques: { Input(0), Input(1), NestedResult(0,1), Result(1) } => 1
2: ex::m::spend(Result(1)); // INVALID: Result(1)'s clique has count > 0
3: sui::coin::into_balance(Result(1));
4: flash::loan::repay(Input(0), NestedResult(0,1), Result(3));

Even though the Coin created in command 1 was not directly involved in the flash loan, it is part of a clique with the outstanding Loan hot potato (NestedResult(0,1)). Repaying the loan before calling spend would make the PTB valid.

Pure value type checking​

The system does not type check pure values until you use them. The first time a pure value appears as an argument expecting type T, the system checks that T is a valid pure type (see the Inputs section) and that the bytes deserialize to T. Each distinct type creates a separate typed copy of the bytes, so you can use the same pure input at multiple types as long as the bytes are valid for each. For example, you can use the same bytes as an ASCII string std::ascii::String and as a UTF-8 string std::string::String. The system treats each typed copy as its own input, so mutations affect only that type's value, not all values created from the same bytes.

Publish and upgrade rules​

Both commands embed their module bytes and dependency IDs directly in the command structure, however, these are not PTB Argument values.

Publish:

  • Returns a single sui::package::UpgradeCap.
  • After the runtime stages the package, it calls each module's init function (if present) in the order you provide the modules (in the vector of serialized bytes). init receives &mut TxContext and optionally a one-time witness. Additional init arguments are not yet supported but are planned.
  • The package is available to init functions during execution. The runtime stages it before they run.

Upgrade:

  • Takes exactly one PTB argument: a sui::package::UpgradeTicket (by value).
  • Returns a single sui::package::UpgradeReceipt.
  • Does not call init functions. Newly added modules in an upgrade cannot have init functions.
  • The module digest and package ID in the ticket must match exactly.
  • The runtime enforces the upgrade policy (compatible, additive, dep-only) from the ticket.

End of execution​

At the end of execution, the runtime checks the remaining values and calculates the effects for the transaction.

For inputs:

  • The runtime skips remaining immutable or read only input objects (no modifications made).

  • The runtime returns remaining mutable input objects to their original owners (shared remain shared, owned remain owned).

  • The runtime drops remaining pure input values (all permissible types have copy and drop).

  • The transaction can only delete or re-share shared objects. Any other operation (wrap, transfer, freezing) results in an error.

For results:

  • The runtime drops remaining results with the drop ability.

  • If a value has copy but not drop, its last usage must have been by-value (treated as a move).

  • Otherwise, the runtime returns an error for unused values without drop.

For gas:

The runtime returns any remaining SUI deducted from the gas coin at the beginning of execution to the coin, even if the owner has changed. The runtime deducts the maximum possible gas at the beginning of execution and returns unused gas at the end (all in SUI). Because you can only take the gas coin by value with TransferObjects, no command can wrap or delete it.

The execution layer then passes out the total effects (created, mutated, and deleted objects), and the Sui network applies them.

Execution example​

By following each command's execution, you can see how inputs flow through commands, how results accumulate, and how the final transaction effects are determined.

Suppose you want to buy 2 items from a marketplace costing 100 MIST. You keep one item for yourself and send the other item plus the remaining coin to a friend at address 0x808.

{
inputs: [
Pure(/* @0x808 BCS bytes */ ...),
Object(SharedObject { /* Marketplace shared object */ id: market_id, ... }),
Pure(/* 100u64 BCS bytes */ ...),
]
commands: [
SplitCoins(GasCoin, [Input(2)]),
MoveCall("some_package", "some_marketplace", "buy_two", [], [Input(1), NestedResult(0, 0)]),
TransferObjects([GasCoin, NestedResult(1, 0)], Input(0)),
MoveCall("sui", "tx_context", "sender", [], []),
TransferObjects([NestedResult(1, 1)], NestedResult(3, 0)),
]
}

The inputs include the friend's address, the marketplace object, and the coin split value. The commands split off the coin, call the marketplace function, send the gas coin and one object, grab your address (through sui::tx_context::sender), and send the remaining object to yourself.

Command 0: SplitCoins(GasCoin, [Input(2)])

Accesses the gas coin by mutable reference and loads Input(2) as 100u64 (copied, not moved). Creates a new coin.

StateValue
Gas coin balance499,900 MIST
Result[Coin<SUI> { id: new_coin, value: 100 }]

Command 1: MoveCall("some_package", "some_marketplace", "buy_two", ...)

Calls buy_two(marketplace: &mut Marketplace, coin: Coin<SUI>, ctx: &mut TxContext): (Item, Item). Uses Input(1) by mutable reference (marketplace) and NestedResult(0, 0) by value (coin is moved and consumed).

StateValue
Results[0][0]moved
Result[Item { id: id1 }, Item { id: id2 }]

Command 2: TransferObjects([GasCoin, NestedResult(1, 0)], Input(0))

Transfers the gas coin and first item to 0x808. The command takes both objects by value (moves them).

StateValue
Gas coinmoved
Results[1][0]moved
Result[]

Command 3: MoveCall("sui", "tx_context", "sender", [], [])

Calls sender(ctx: &TxContext): address. Returns the sender's address.

StateValue
Result[sender_address]

Command 4: TransferObjects([NestedResult(1, 1)], NestedResult(3, 0))

Transfers the second item to the sender. The command moves the item by value and copies the address by value.

StateValue
Results[1][1]moved
Result[]

Initial state​

Gas Coin: Coin<SUI> { id: gas_coin, balance: 1_000_000u64 }
Inputs: [Pure(@0x808), Marketplace { id: market_id }, Pure(100u64)]
Results: []

After the runtime deducts the maximum gas budget of 500_000:

Gas Coin: Coin<SUI> { id: gas_coin, balance: 500_000u64 }

Final state​

Gas Coin: _ (moved)
Inputs: [Pure(@0x808), Marketplace { id: market_id } (mutated), Pure(100u64)]
Results: [
[_],
[_, _],
[],
[sender_address],
[]
]

End of execution checks​

  • Input objects: Gas coin moved. Marketplace remains shared (mutated).

  • Results: All remaining values have drop ability (Pure inputs, sender's address). All other results moved.

  • Shared objects: Marketplace not moved, remains shared.

Transaction effects:

  • Coin split from gas (new_coin) does not appear (created and deleted in same transaction).

  • Gas coin and Item { id: id1 } transferred to 0x808. Remaining gas returned to gas coin despite owner change.

  • Item { id: id2 } transferred to sender.

  • Marketplace object returned as shared (mutated).