Transactions

Managing and sending transactions is an important part of any Solana client. To help manage them, Umi provides a bunch of components:

Transactions and Instructions

Umi defines its own set of interfaces for transactions, instructions and all other related types. Here's a quick overview of the most important ones with a link to their API documentation:

  • Transaction: A transaction is composed of a versioned transaction message, a list of required signatures and a serialized version of its message so it can be easily signed.

  • TransactionMessage: A transaction message is composed of all required public keys, one or many compiled instructions using indexes instead of public keys, a recent blockhash and other attributes such as its version. A transaction message can have one of the following versions:

    • Version: "legacy": The first Solana iteration of the transaction message.

    • Version: 0: The first transaction message version that introduces transaction versioning. It also introduces address lookup tables.

  • Instruction: An instruction is composed of a program id, a list of AccountMeta and some serialized data. Each account AccountMeta is composed of a public key, a boolean indicating whether it will be signing the transaction and another boolean indicating whether it's writable or not.

To create a new transaction, you may use the create method of the TransactionFactoryInterface. For instance, here's how you'd create a version 0 transaction with a single instruction:

const transaction = umi.transactions.create({
  version: 0,
  blockhash: (await umi.rpc.getLatestBlockhash()).blockhash,
  instructions: [myInstruction],
  payer: umi.payer.publicKey,
})

The transaction factory interface can also be used to serialize and deserialize transactions and their messages.

const mySerializedTransaction = umi.transactions.serialize(myTransaction)
const myTransaction = umi.transactions.deserialize(mySerializedTransaction)
const mySerializedMessage = umi.transactions.serializeMessage(myMessage)
const myMessage = umi.transactions.deserializeMessage(mySerializedMessage)

All of this is nice but can be a bit tedious to build every single time we want to send a transaction to the blockchain. Fortunately, Umi provides a TransactionBuilder that can help with that.

Transaction Builders

Transaction builders are immutable objects that can be used to gradually build transactions until we are ready to build, sign and/or send them. They are composed of a list of WrappedInstruction and various options that can be used to configure the built transaction. A WrappedInstruction is a simple object containing an instruction and a bunch of other attributes. Namely:

  • A bytesCreatedOnChain attribute that, if the instruction ends up creating accounts, tells us how many bytes they will take on chain (including the account headers).

  • and a signers array so that we know which signers are required for this particular instruction as opposed to the whole transaction. This enables us to split the transaction builder into two without losing any information as we will see later.

We can create a new transaction builder using the transactionBuilder function and add instructions to it using its add method. You may also use the prepend method to push an instruction at the beginning of the transaction.

let builder = transactionBuilder()
  .add(myWrappedInstruction)
  .add(myOtherWrappedInstruction)
  .prepend(myFirstWrappedInstruction)

Since transaction builders are immutable, we must be careful to always assign the result of the add and prepend methods to a new variable. The same goes for any other method that updates the transaction builder by returning a new one.

builder = builder.add(myWrappedInstruction)
builder = builder.prepend(myWrappedInstruction)

Note that either of these methods also accepts other transaction builders and will merge them into the current one. In practice, this means program libraries can write (or auto-generate) their own helper methods that return transaction builders so they can be composed together by the end-user.

import { transferSol, addMemo } from '@metaplex-foundation/mpl-toolbox';
import { createNft } from '@metaplex-foundation/mpl-token-metadata';

let builder = transactionBuilder()
  .add(addMemo(umi, { ... }))
  .add(createNft(umi, { ... }))
  .add(transferSol(umi, { ... }))

You may also split a transaction builder into two if, for instance, the transaction that would be created from the original builder would be too big to be sent to the blockchain. To do so, you may use the splitByIndex method or the more dangerous unsafeSplitByTransactionSize method. Make sure to read the comment on the API reference for the latter.

const [builderA, builderB] = builder.splitByIndex(2)
const splitBuilders = builder.unsafeSplitByTransactionSize(umi)

And there's much more you can do with transaction builders. Feel free to read the API reference to learn more but here's a quick overview of some of the other methods that can configure our transaction builders.

// Setters.
builder = builder.setVersion(myTransactionVersion) // Sets the transaction version.
builder = builder.useLegacyVersion() // Sets the transaction version to "legacy".
builder = builder.useV0() // Sets the transaction version to 0 (default).
builder = builder.empty() // Removes all instructions from the builder but keeps the configurations.
builder = builder.setItems(myWrappedInstructions) // Overwrite the wrapped instructions with the given ones.
builder = builder.setAddressLookupTables(myLuts) // Set the address lookup tables, only for version 0 transactions.
builder = builder.setFeePayer(myPayer) // Set a custom fee payer.
builder = builder.setBlockhash(myBlockhash) // Set the blockhash to use for the transaction.
builder = await builder.setLatestBlockhash(umi) // Fetch the latest blockhash and use it for the transaction.

// Getters.
const transactionSize = builder.getTransactionSize(umi) // Return the size in bytes of the built transaction.
const isSmallEnough = builder.fitsInOneTransaction(umi) // Whether the built transaction would fit in one transaction.
const transactionRequired = builder.minimumTransactionsRequired(umi) // Return the minimum number of transactions required to send all instructions.
const blockhash = builder.getBlockhash() // Return the configured blockhash if any.
const feePayer = builder.getFeePayer(umi) // Return the configured fee payer or uses `umi.payer` if none is configured.
const instructions = builder.getInstructions(umi) // Return all unwrapped instructions.
const signers = builder.getSigners(umi) // Return all deduplicated signers, including the fee payer.
const bytes = builder.getBytesCreatedOnChain() // Return the total number of bytes that would be created on chain.
const solAmount = await builder.getRentCreatedOnChain(umi) // Return the total number of bytes that would be created on chain.

Notice that we are passing an instance of Umi to some of them. This is because they will need to access some of Umi's core interfaces to perform their task.

Now that we have our transaction builder ready, let's see how we can use it to build, sign and send transactions.

Building and signing transactions

When you're ready to build your transaction, you may simply use the build method. This method will return a Transaction object that you can then sign and send to the blockchain.

const transaction = builder.build(umi)

Note that the build method will throw an error if no blockhash was set on the builder. If you wish to build the transaction using the latest blockhash, you may use the buildWithLatestBlockhash method instead.

const transaction = await builder.buildWithLatestBlockhash(umi)

At this point, you could use the built transaction and get all deduplicated signers from the builder via the getSigners method to sign it (See Signing transactions for more details). However, Umi provides a buildAndSign method that can do that for you. When using buildAndSign, the latest blockhash will be used if and only if it was not set on the builder.

const signedTransaction = await builder.buildAndSign(umi)

Sending transactions

Now that we have a signed transaction, let's see how we can send it to the blockchain.

One way to do this is to use the sendTransaction and confirmTransaction methods of the RpcInterface like so. When confirming the transaction, we have to provide a confirm strategy which can be of type blockhash or durableNonce, each of them requiring a different set of parameters. Here's how we would send and confirm a transaction using the blockhash strategy.

const signedTransaction = await builder.buildAndSign(umi)
const signature = await umi.rpc.sendTransaction(signedTransaction)
const confirmResult = await umi.rpc.confirmTransaction(signature, {
  strategy: { type: 'blockhash', ...(await umi.rpc.getLatestBlockhash()) },
})

Since this is a very common task, Umi provides helper methods on the transaction builder to do this for us. That way, the code above can be rewritten as follows.

const confirmResult = await builder.sendAndConfirm(umi)

This will build and sign the transaction using the buildAndSign method before sending and confirming the transaction using the blockhash strategy by default. It will reuse the transaction blockhash for the confirm strategy to avoid the extra Http request when applicable. That being said, you may still explicitly provide a confirm strategy or set any options like so.

const confirmResult = await builder.sendAndConfirm(umi, {
  // Send options.
  send: {
    skipPreflight: true,
  },

  // Confirm options.
  confirm: {
    strategy: {
      type: 'durableNonce',
      minContextSlot,
      nonceAccountPubkey,
      nonceValue,
    },
  },
})

Also note that you may send a transaction without waiting for it to be confirmed via the send method of the transaction builder.

const signature = await builder.send(umi)

Using address lookup tables

Starting from version 0 transactions, you may use address lookup tables to reduce the size of transactions.

const myLut: AddressLookupTableInput = {
  publicKey: publicKey('...') // The address of the lookup table account.
  addresses: [ // The addresses registered in the lookup table.
    publicKey('...'),
    publicKey('...'),
    publicKey('...'),
  ]
}

builder = builder.setAddressLookupTables([myLut]);

To create an address lookup table, you might be interested in the @metaplex-foundation/mpl-toolbox package which provides helpers for creating them.

import { createLut } from '@metaplex-foundation/mpl-toolbox'

// Create a lookup table.
const [lutBuilder, lut] = createLut(umi, {
  recentSlot: await umi.rpc.getSlot({ commitment: 'finalized' }),
  addresses: [myAddressA, myAddressB, myAddressC],
})
await lutBuilder.sendAndConfirm(umi)

// Later on, use the created lookup table.
myBuilder = myBuilder.setAddressLookupTables([lut])

Fetching sent transactions

Let's now take a look at how to fetch a transaction that was sent to the blockchain.

For that, we can use the getTransaction method of the RpcInterface and provide the signature of the transaction we want to fetch.

const transaction = await umi.rpc.getTransaction(signature)

This will return an instance of TransactionWithMeta which is a superset of Transaction and contains an extra meta property that provides additional information on the transaction. For instance, you could access the logs of a sent transaction like so.

const transaction = await umi.rpc.getTransaction(signature)
const logs: string[] = transaction.meta.logs

Last updated