Build a transaction
Construct a new transaction message and sign it
An immutable affair
Before we start building our transaction, let's take a moment to discuss immutability in TypeScript. This is relevant for understanding how we can build transaction messages in a functional way.
Consider the following mutable variable:
As you can see by hovering on top of the person
variable, TypeScript assigns the type { name: string }
to it. Now imagine we wanted to gradually grow this type by adding more properties to it. Say we wanted to add an age
and a wallet
property to it by using the following functions:
One way to tackle this is to mutate the variable at each step like so.
But as you can see, even after updating our variable, TypeScript still believes the person
variable is of type { name: string }
. This is because TypeScript doesn't re-assign types to variables when they are mutated.
Fortunately, the Kit library offers a functional API which means its types are designed to be immutable. So, instead of trying to grow the person
variable by mutating it, it creates new variables with new types at each step.
The downside is we now need to create a new variable at each step. This is where the pipe
function comes in. It takes a starting value and runs it through a series of functions, one after the other, returning the final result. This lets us build up the value and its type without needing extra variables.
This is exactly how we'll build our transaction in the following sections. Similarly to this person example, transaction messages can grow in a variety of ways and the ability to use composable functions to gradually construct them is a powerful tool.
With that in mind, let's start building our transaction message, starting with the initial value of our pipe
.
Create the base transaction message
The first step in building a transaction is to create a new empty transaction message. This message will eventually contain all the necessary information to send a transaction to the Solana network before being compiled and signed.
To create one, we can use the createTransactionMessage
function. This function requires a version
parameter which is used to determine the format of the transaction. At the time of writing, 0
is the latest version available so we'll use that.
Set the fee payer
Next, we need to set a fee payer for our transaction. This is the wallet that will pay for the transaction to be sent to the network. For this, we can use the setTransactionMessageFeePayerSigner
function which requires a TransactionSigner
object. Since we already have a signer in our Client
object, let's use it.
Set the transaction lifetime
We also need to specify a lifetime for our transaction. This lifetime defines a period of time during which the transaction is valid.
There are currently two lifetime strategies available:
- The blockhash strategy: Given a block height — i.e. number of blocks since the genesis block — the transaction will be valid until the next block at the given height is produced. This is the most common strategy.
- The durable nonce strategy: This strategy requires setting up a durable nonce account and advancing its value in the first instruction of the transaction. This is useful for transactions that need to be valid for long periods of time.
For this tutorial, we'll use the blockhash strategy. To do this, we can use the setTransactionMessageLifetimeUsingBlockhash
function which requires an object with a recent blockhash and a block height. Fortunately, this is exactly what the getLatestBlockhash
RPC method provides.
Add instructions
Last but not least, we need to add our instructions to the transaction message. Since we've already built our instructions in the previous article, we can add them to our transaction message using the appendTransactionMessageInstructions
function like so.
Set the compute limit dynamically
As mentioned in the previous article, setting a compute limit on a transaction as close as possible to the actual cost of running it will have many benefits, such as: increasing the likelihood of the transaction being scheduled, increasing the number of transactions that can be produced in a block and reducing the price of your priority fees, if any.
A good way to estimate how much compute unit a transaction will need is to simulate it and measure its cost. This can be done using the estimateComputeUnitLimitFactory
function from @solana-program/compute-budget
to get the estimated compute limit before setting it on the transaction message using the getSetComputeUnitLimitInstruction
function from the same package. Note that this must be done after the transaction message is fully configured to ensure the estimate is as accurate as possible.
Since this is a common operation, let's add a helper function to our Client
type so we can reuse it any time we need to set a compute unit limit.
First, let's create a new estimateAndSetComputeUnitLimitFactory
function that wraps the estimateComputeUnitLimitFactory
to return a function that does everything in the code snippet above.
Next, let's use the return type of that function to provide a new estimateAndSetComputeUnitLimit
function in our Client
type and export it in our createClient
helper.
Finally, we can now use this new estimateAndSetComputeUnitLimit
helper at the end of our pipe
call to set the compute unit limit on our transaction message. Note that, since estimateAndSetComputeUnitLimit
is asynchronous, we now need to await on the return value of our pipe
.
Sign the transaction message
Our transaction message is now fully configured and ready to be signed. Since we have been providing signer objects every step of the way, our transaction message already knows how to sign itself. All that is left to do is call signTransactionMessageWithSigners
. This helper function will extract and deduplicate all the signers from the transaction message and use them to sign the message. As the message is signed, it is compiled into a new Transaction
type that contains the compiled message and all of its signatures.
Ensure the transaction is sendable
One last step before we can send our transaction is to ensure it is in a valid state. We already know that the transaction is fully signed since the signTransactionMessageWithSigners
ensures that for us. However, another requirement we need to consider is whether or not the size of the transaction exceeds the maximum size allowed by the Solana network.
Fortunately for us, there is a helper function called assertIsSendableTransaction
that will check all these requirements for us and throw an error if the transaction is not valid. After a successful call to this function, the provided transaction will be upgraded to a SendableTransaction
type which is a requirement for helper functions that send transactions to the network.
The createMint
helper
And we finally have our fully signed and sendable transaction! In the next article, we'll learn how to send it and wait for its confirmation but before we do, here's our updated createMint
function including everything we've learned so far.