Instruction Plans
Compose instructions into multi-step operations
Introduction
Instruction plans describe operations that go beyond a single instruction and may even span multiple transactions.
They define a set of instructions that must be executed following a specific order. For instance, imagine we wanted to create an instruction plan for a simple escrow transfer between Alice and Bob. First, both would need to deposit their assets into a vault. This could happen in any order. Then and only then, the vault can be activated to switch the assets. Alice and Bob can now both withdraw each other's assets (again, in any order). Here's how we could describe an instruction plan for such an operation.
As you can see, instruction plans don't concern themselves with:
- Adding structural instructions — e.g. compute budget limits and prices.
- Building transaction messages from these instructions. That is, planning how many can fit into a single instruction, adding a fee payer, a lifetime, etc.
- Compiling, signing and sending transactions to the network.
Instead, they solely focus on describing operations and delegate all that to two components introduced in this package:
- Transaction planner: builds transaction messages from an instruction plan and returns an appropriate transaction plan.
- Transaction plan executor: compiles, signs and sends transaction plans and returns a detailed result of this operation.
This separation of concerns not only improves the developer experience but also allows program maintainers to offer helper functions that go beyond a single instruction, while leaving their consumers to decide how they want these operations to materialise.
Installation
Instruction plans are included within the @solana/kit
library but you may also install them using their standalone package.
Creating instruction plans
This package offers several helpers to help you compose your own instruction plans. Let's have a look at them.
Single instructions
The most trivial way to create an instruction plan is to use the singleInstructionPlan
helper to create a plan with only one instruction.
Sequential plans
The sequentialInstructionPlan
helper allows you to create plans from other plans that must be executed sequentially. Therefore, in the example below, we guarantee that Bob will receive assets from Alice before sending them to Carla.
Note that the sequentialInstructionPlan
helper also accept Instruction
objects directly and automatically wraps them in singleInstructionPlans
. Therefore the following is equivalent to the previous example.
A nonDivisibleSequentialInstructionPlan
helper is also available to define sequential plans whose inner instructions should all be executed atomically. That is, either in a single transaction or in a transaction bundle when not possible.
In this example, we know that both instruction will either succeed or fail together.
Parallel plans
The parallelInstructionPlan
function can be used to create plans from other plans that can be executed in parallel. This means direct children of this plan can be executed in separate parallel transactions without consequence. For instance, in the example below, Alice can transfer assets to both Bob and Carla in any order without affecting the final outcome.
The parallelInstructionPlan
also accepts Instruction
object directly and therefore the previous example can be simplified as:
Message packer plans
Message packer plans are a bit special. They can dynamically pack instructions into transaction messages. This is particularly useful when packing instructions whose size will vary based on the available space left on the transaction message being packed.
For instance, imagine a write
instruction on a program that gradually write data from instructions into a buffer account. The instruction data used in this case will ideally be as long as the transaction message can fit. Using the getLinearMessagePackerInstructionPlan
helper, we can create an instruction plan that does just that.
As you can see, the getLinearMessagePackerInstructionPlan
helper accepts a totalLength
attribute representing the total amount of bytes we eventually want to write to the buffer. The purpose of the getInstruction
function is then to generate these write
instructions at the provided positions.
There also exists a getReallocMessagePackerInstructionPlan
helper that works similarly but whose purpose is to pack multiple realloc instructions to help resize an account.
Whilst these helpers are fairly situational, you can create any custom message packer as long as you implement the following interfaces.
Most of your custom logic will live in the packMessageToCapacity
function whose purpose is to pack the provided message with as many instruction datas as possible or throw when it isn't achievable. The done
function lets us know if there is any instruction data left to pack. See MessagePacker
for more information.
Combining plans
It is worth noting that more complex operations can be created by combining plans together. For instance, the following plan will:
- Create two accounts in parallel, one Buffer account and one Metadata account.
- The Buffer account will be written to via multiple parallel
write
instructions. - The Metadata account will be created and initialized atomically.
- The Buffer account will be written to via multiple parallel
- Once both of these accounts are created, the data in the Buffer account will be used to set the data in the Metadata account before being closed.
Planning instructions
Once we have an instruction plan, the first step is to build transaction messages from it in the most optimal way whilst satisfying all the constraints defined in the instruction plan. This is the role of transaction planners.
Transaction planners
Transaction planners are defined as simple abortable functions that transform a given instruction plan into a set of built transaction messages called a transaction plan.
Creating a transaction planner
To spin up a transaction planner, you may use the createTransactionPlanner
helper. This helper requires a createTransactionMessage
function that tells us how each new transaction message should be created before being packed with instructions.
For instance, in the example below we create a new planner such that each planned transaction message will be using version 0 and using the payer
signer as a fee payer.
Additionally, the onTransactionMessageUpdated
function may be provided to update transaction messages during the planning process. This function will be called whenever a transaction message is updated — e.g. when new instructions are added. It accepts the updated transaction message and must return a transaction message back, even if no changes were made.
In the example below, we check if the packed transaction contains an instruction that transfers SOL and, if so, add a guard instruction that ensures no more than 1 SOL is transfered.
Check out the Recipes section at this end of this guide for ideas of what can be achieved with this API.
Transaction plans
Since transaction planners output transaction plans, it may be useful to see what these look like.
They work similarly to instruction plans but they wrap transaction messages instead of instructions and do not contain message packers.
singleTransactionPlan
. Wraps a built transaction message.sequentialTransactionPlan
: Wraps other transaction plans that must be executed sequentially.nonDivisibleSequentialTransactionPlan
: Wraps other transaction plans that must be executed sequentially and atomically. Since atomicity is at the transaction level, this suggests transaction bundles should be used if possible. Otherwise, the plan should fail to execute.parallelTransactionPlan
: Wraps other transaction plans that may be executed in parallel.
Advanced transaction planners
Whilst the createTransactionPlanner
helper is designed to suit most projects, it may not suit yours. If so, you may create your own by offering a function that satisfy the following signature.
Executing transactions
Now that we have obtained a transaction plan from our instruction plan, the next and final step is to send these transactions using a transaction plan executor.
Transaction plan executors
Transaction plan executors are defined as abortable functions that transform a given transaction plan into a mirrored data structure that contains the execution status of each transaction. That data structure is called a transaction plan result.
Creating a transaction plan executor
To spin up a transaction plan executor, you may use the createTransactionPlanExecutor
helper. This helper requires an executeTransactionMessage
function that tells us how each transaction message should be executed when encountered during the execution process.
This function accepts a transaction message and must return an object containing the successfully executed transaction, along with an optional context object that can be used to pass additional information about the execution.
For instance, in the example below we create a new executor such that each transaction message will be assigned the latest blockhash lifetime before being signed and sent to the network using the provided RPC client.
Check out the Recipes section at this end of this guide for ideas of what can be achieved with this API.
Transaction plan results
When you execute a transaction plan, you get back a TransactionPlanResult
that tells you what happened during execution. This result object has the same tree structure as your original transaction plan, but includes execution information for each transaction message.
Each transaction message in your plan can have one of three execution outcomes:
- Successful - The transaction was sent and confirmed. You get the original transaction message, the executed
Transaction
object and any context data. - Failed - The transaction encountered an error. You get the original transaction message and the error that caused the failure.
- Canceled - The transaction was skipped because an earlier transaction failed or the operation was aborted. You only get the original transaction message.
The result structure mirrors your transaction plan structure:
- Single transaction messages become
SingleTransactionPlanResult
with the original message plus execution status - Sequential plans become
SequentialTransactionPlanResult
containing child results - Parallel plans become
ParallelTransactionPlanResult
containing child results
Failed transaction executions
When a transaction plan executor — created via the createTransactionPlanExecutor
helper — encounters a failed transaction, it will cancel all remaining transactions in the plan. The executor will then throw a SOLANA_ERROR__INSTRUCTION_PLANS__FAILED_TO_EXECUTE_TRANSACTION_PLAN
error code.
This error contains a transactionPlanResult
property (accessible as a non-enumerable property) that provides detailed information about which transactions succeeded, failed, or were canceled.
Advanced transaction plan executors
Whilst the createTransactionPlanExecutor
helper is designed to suit most projects, it may not suit yours. If so, you may create your own by offering a function that satisfies the following signature.
This allows you to implement custom execution strategies, such as using transaction bundles for atomic execution or adding custom transaction prioritization.
Recipes
Here are some common patterns and recipes for using instruction plans effectively.
Setting priority fees
You can set priority fees by using the setTransactionMessageComputeUnitPrice
helper from @solana-program/compute-budget
in your createTransactionMessage
function.
Estimating compute units
You can estimate and set compute unit limits dynamically by using a two-step process:
- First, add a provisional compute unit limit instruction (if missing) in your transaction planner.
- Then, estimate and update it right before sending the transaction.
You may use the fillProvisorySetComputeUnitLimitInstruction
helper in your transaction planner so the instruction is accounted for when planning transaction messages.
Then, use the estimateAndUpdateProvisoryComputeUnitLimitFactory
helper in your transaction plan executor to estimate the compute units right before sending the transaction.
Durable nonce executor
You may create transaction plans that use durable nonces for offline transaction signing by using the setTransactionMessageLifetimeUsingDurableNonce
helper in your transaction planner.
Then, make sure to use the sendAndConfirmDurableNonceTransactionFactory
helper in your transaction plan executor in order to use the appropriate confirmation strategy for your transactions.