Discover more from mrgn
Sui’s Programmable Transaction Blocks in Rust
Guide to using Sui PTBs with the Rust SDK
Programmable Transaction Blocks (PTB) are a powerful concept in Sui. They allow users to atomically execute a sequence (block) of transactions. For the ones familiar with Solana, it is tempting to make the following comparison:
Sui PTB ↔ Solana transaction
Sui transaction ↔ Solana instruction
While there is some amount of equivalence to draw, a crucial difference lies is the ability for Sui PTBs to easily route inputs/results between internal transactions. This routing enables developers to craft complex, cross-contract transactions entirely client-side, where a custom contract deployment would be required on other chains. While there is also likely compromise involved in the way those PTBs are implemented compared to other chains’ equivalents, this alone is a pretty compelling feature.
While there are a few examples available to demonstrate how to use PTBs in Typescript (including the official doc), the rust examples are scarcer and less advanced. This seems to have a few developers stumble, as using them in Rust requires to understand more precisely the types manipulated.
This article provides some level of explanation on a sample crate which constructs and runs a complex PTB using the Rust SDK. The code is available here. If that is all you need you’re all set, but if you’d like some additional details you are welcome to read on.
Show me some code
Let’s take an utterly useless and complex PTB as our use case:
This masterpiece is a representation of a PTB which performs a variety of actions, such as:
reading coin objects values
performing math on the outputs of these values
creating objects (a kiosk)
(the actions in themselves are of little importance)
Creating a PTB boils down to a few tasks:
declaring a PTB builder
defining entry arguments, be them pure (e.g. u64s, addresses, IDs), shared objects (e.g. liquidity pools in most cases), or owned/immutable objects (e.g. coins, Clock)
creating relevant transactions in order, using the usual
functiontriplet, and providing type arguments and call arguments for each one of them, understanding that the call arguments can either be:
the ones defined by you from external data (e.g. coins, pure arguments)
all or part of the result of any prior command in the block
wrapping up the PTB
Let’s illustrate each of these through our example code.
#1 Declaring the builder
That was easy.
#2 Defining entry arguments
In this example, we are providing exactly 2 entry arguments:
Coinwe are going to manipulate
a number, which we use in a math function call transaction
They are constructed using external data obtained through RPC calls like so:
Prefer being explicit when creating numeric pure arguments (e.g.
let number_two_arg = pt_builder.pure(2u64)?;) or you will face
The reason for it is that Rust will default to 32-bit integers when given the choice, which will lead to this argument being serialized as such, and a subsequent
ExecutionErrorwhen the VM attempts to deserialize this argument as the
u64the function expects, for instance.
#3 Creating individual transactions
These commands can be move calls or native function calls. For instance our sample PTB makes many move calls, but also calls
TransferObjects a couple times (a native function).
Let’s illustrate each.
As mentioned above, the arguments are standard, with the usual function “address” triplet, the type arguments, and the call arguments.
The important thing to notice is the type of the arguments provided:
Argument. Looking at the sui crates, it shows:
GasCoin here, all there is to understand is that any “entry” input you provide (like
number_two_arg in the previous section) have their contents stored in the builder
Argument::Input, or in other words: a simple index pointing to the relevant input.
Similarly, on the call result side,
ProgrammableTransactionBuilder::programmable_move_call returns an
Argument, and specifically an
Argument::Result. As shown above, it is no different from
Input in that it is an index
i pointing to the result of the
ith transaction in the block.
Coming back to the
join move call snippet above, you can see that the first argument passed is an entry arg, i.e.
Argument::Input, whereas the second one is a reference to the result of a prior move call, i.e.
Argument::Result. This shows how inputs/results can be weaved along calls to allow execution of complex logic across any contract, all from the client side.
The last variant in the
NestedResult, can be used in the case where several variables are returned from a call (i.e. a tuple), and you need to direct one of those items as input to posterior transactions. The idea is then to:
retrieve the underlying result index
iin the relevant call’s
construct the next call’s input as an
Nbeing the index of the item you need in the tuple.
This is illustrated in the following snippet:
This one is pretty self-explanatory. The only point to note is the existence of a couple variants of this transfer function, to accommodate providing
Arguments or generic types (here we are using the former since we are providing a result).
#4 Wrapping up the block
A look under the hood
The sample crate makes use of
A good exercise to really appropriate what happens during PTB construction is to print the PTB itself after it has been
finish()ed, and realize that all there is to it is:
a list of (potentially factored) inputs, e.g.:
a list of commands, some of them potentially referring to one or more of the inputs above, e.g.:
That’s all folks, good luck.
Thanks for reading mrgn! Subscribe for free to receive new posts and support my work.
and unless specified factored in case of duplicate args