Transaction Overview
A big part of the life of a blockchain developer is to create and launch blockchain transactions.
Whether it's an off-chain tool, smart contract code, or a testing scenario, it is important that we have a powerful syntax to express any conceivable transaction.
During the process of creating the development framework, we realized that the following are equivalent to a large extent and could be expressed using the same syntax:
- transactions launched from smart contracts,
- blackbox integration tests,
- off-chain calls.
We called this "unified transaction syntax" or "unified syntax", and the first version of it was released in multiversx-sc version 0.49.0.
In this documentation, you will get a complete explanation of all the features of this syntax, organized around the various components of a blockchain transaction.
Motivation and design
Transactions can be fairly complex, and in order to get them to use the same syntax in very different environments, we had a few challenges:
- Transactions have many fields, which can be configured in many ways. It is important to be able to configure most of them independently, otherwise the framework becomes too large, or unreliable.
- Since this syntax is also used in contracts, it was essential to choose a design that adds almost no runtime and code size overhead. So the syntax must make heavy use of generics, to resolve at compile-time all type checks, conversions, and restrictions.
- Also, because syntax is used in contracts, it had to rely on managed types.
- We wanted as much type safety as possible, so we had to find a way to rely on the ABI to produce type checks both for contract inputs and outputs.
The Tx
object
We decided to model all transactions using a single object, but with exactly 7 generic arguments, one for each of the transaction fields.
Each one of these 7 fields has a trait that governs what types are allowed to occupy the respective position.
All of these positions (except the environment Env
) can be empty, uninitialized. This is signaled at compile time by the unit type, ()
. In fact, a transaction always starts out with all fields empty, except for the environment.
For instance, if we are in a contract and write self.tx()
, the universal start of a transaction, the resulting type will be Tx<TxScEnv<Self::Api>, (), (), (), (), (), ()>
, where TxScEnv<Self::Api>
is simply the smart contract call environment. Of course, the transaction at this stage is unusable, it is up to the developer to add the required fields and send it.
The Tx
fields
We have dedicated a page to each of these 7 fields:
Field | Description |
---|---|
Environment | Some representation of the environment where the transaction runs. |
From | The transaction sender. Implicit for SC calls (the contract is the sender), but mandatory anywhere else. |
To | The receiver. Needs to be specified for any transaction expect for deploys. |
Payment | Optional, can be EGLD, single or multi-ESDT. We also have some payment types that get decided at runtime. |
Gas | Some transactions need explicit gas, others don't. |
Data | Proxies (ideally) or raw |
Result Handlers | Anything that deals with results, from callbacks to decoding logic. |
We could also group them in three broad categories:
- The environment is its own category, pertaining to both inputs and outputs.
- 5 inputs: From, To, Payment, Gas, Data.
- 1 field dealing with the output: the result handlers.
Transaction builder
Now that we've seen what the contents of a transaction are, let's see how we can specify them in code.
Of course, the developer shouldn't access the fields of the transaction directly. There is sort of a builder pattern when constructing a transaction.
In its most basic form, a transaction might be constructed as follows:
self.tx()
.from(from)
.to(to)
.payment(payment)
.gas(gas)
.raw_call("function")
.with_result(result_handler)
While this may look like a traditional OOP builder pattern, there is one important aspect to point out:
Each of these setters outputs a type that is different from its input.
We are not merely setting new data into an existing field, we are also specifying the type of each field, at compile-time. At each step we are not only specifying the data, but also the types.
Even though these look like simple setters, we are first of all constructing a type using this syntax.
Also note that the way these methods are set up, it is impossible to call most of them twice. They only work when the respective field is not yet set (of unit type ()
), so writing something like self.tx().from(a).from(b)
causes a compiler error.
Here we have some of the most common methods used to construct a transaction:
Field | Filed name | Initialize with |
---|---|---|
Environment | env | .tx() |
From | from | .from(...) |
To | to | .to(...) |
Payment | payment | .payment(...) |
Gas | gas | .gas(...) |
Data | data | .typed(...) (ideally) .raw_call(...) .raw_deploy() .raw_upgrade() |
Result Handlers | result_handler | .callback(...) .returns(...) .with_result(...) |
The list is by no means exhaustive, it is just an initial overview. Please find the full documentation on each of the linked pages.
Execution
Ultimately, the purpose of a transaction is to be executed. Simply constructing a transaction has no effect in itself. So we must finalize each transaction construct with a call that sends it to the blockchain.
The allowed execution methods depend on the environment. More specifically:
- In a contract, the options are
.transfer()
,.transfer_execute()
,.async_call_and_exit()
,.sync_call()
, etc. - In tests just
.run()
is universal. - In interactors, we call
.run().await
. It's the same method name, but this time it's asynchronous Rust, so it can only be called in anasync
function, and.await
is necessary.
More on this in the launch page.
Map of the fields types
This is a graph of what the common types are that fit in each of the transaction fields.
Map of the setters
Constructing a transaction is similar to exploring a map, or running a finite state machine. You start with an initial position, and then get to choose in which direction to go.
Choosing a path at one point closes off many other options. The compiler is always guiding us and preventing us from ending up with an invalid transaction.
Here is a map of all the paths you can take when configuring a transaction. The fields are mostly independent, so the map is split into 7 sections. See more details on each of their respective pages.