Multicall in Solana: Batching Transactions and Transaction Size Limit
Solana has multicall built in
In Ethereum, we use the multicall pattern if we want to batch multiple transactions together atomically. If one fails, the rest fails.
Solana has this built into the runtime, so we don’t need to implement a multicall. In the example below, we initialize an account and write to it in one transaction — without using init_if_needed
.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Batch } from "../target/types/batch";
describe("batch", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.Batch as Program<Batch>;
it("Is initialized!", async () => {
const wallet = anchor.workspace.Batch.provider.wallet.payer;
const [pda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
const initTx = await program.methods.initialize().accounts({pda: pda}).transaction();
// for u32, we don't need to use big numbers
const setTx = await program.methods.set(5).accounts({pda: pda}).transaction();
let transaction = new anchor.web3.Transaction();
transaction.add(initTx);
transaction.add(setTx);
await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [wallet]);
const pdaAcc = await program.account.pda.fetch(pda);
console.log(pdaAcc.value); // prints 5
});
});
Here is the corresponding Rust code:
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE");
#[program]
pub mod batch {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
pub fn set(ctx: Context<Set>, new_val: u32) -> Result<()> {
ctx.accounts.pda.value = new_val;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = signer, space = size_of::<PDA>() + 8, seeds = [], bump)]
pub pda: Account<'info, PDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Set<'info> {
#[account(mut)]
pub pda: Account<'info, PDA>,
}
#[account]
pub struct PDA {
pub value: u32,
}
Some comments on the code above:
- When passing a
u32
value or smaller to Rust, we do not need to use a Javascript bignumber. - Instead of doing
await program.methods.initialize().accounts({pda: pda}).rpc()
we do await program.methods.initialize().accounts({pda: pda}).transaction()
to create a transaction.
Solana Transaction Size Limit
The total size of a Solana transaction cannot exceed 1232 bytes.
The implication of this is that you will not be able to batch an “unlimited” number of transactions and just pay more gas like you would in Ethereum.
Demonstrating atomicity of batched transactions
Let’s alter our set
function in Rust to always fail. This will help us see that the initialize
transaction gets rolled back if one of the subsequent batched transactions fails.
The following Rust program always returns an error when set
is called:
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE");
#[program]
pub mod batch {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
pub fn set(ctx: Context<Set>, new_val: u32) -> Result<()> {
ctx.accounts.pda.value = new_val;
return err!(Error::AlwaysFails);
}
}
#[error_code]
pub enum Error {
#[msg(always fails)]
AlwaysFails,
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = signer, space = size_of::<PDA>() + 8, seeds = [], bump)]
pub pda: Account<'info, PDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Set<'info> {
#[account(mut)]
pub pda: Account<'info, PDA>,
}
#[account]
pub struct PDA {
pub value: u32,
}
The following Typescript code sends a batch transaction of initialization and set:
import * as anchor from "@coral-xyz/anchor";
import { Program, SystemProgram } from "@coral-xyz/anchor";
import { Batch } from "../target/types/batch";
describe("batch", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.Batch as Program<Batch>;
it("Set the number to 5, initializing if necessary", async () => {
const wallet = anchor.workspace.Batch.provider.wallet.payer;
const [pda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
// console.log the address of the pda
console.log(pda.toBase58());
let transaction = new anchor.web3.Transaction();
transaction.add(await program.methods.initialize().accounts({pda: pda}).transaction());
transaction.add(await program.methods.set(5).accounts({pda: pda}).transaction());
await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [wallet]);
});
});
When we run the test, then query the local validator for the pda account, we see it doesn’t exist. Even though the initialize transaction was first, the set transaction which came after reverted, causing the entire transaction to get cancelled, and thus no account was initialized.
“Init if needed” on the frontend
You can simulate the behavior of init_if_needed
using frontend code while having a separate initialize
function. From the user’s perspective however, this will all get smoothed out as they don’t have to issue multiple transactions when using an account for the first time.
To determine if an account needs to be initialized, we check if it has zero lamports or is owned by the system program. Here is how we can do so in Typescript:
import * as anchor from "@coral-xyz/anchor";
import { Program, SystemProgram } from "@coral-xyz/anchor";
import { Batch } from "../target/types/batch";
describe("batch", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.Batch as Program<Batch>;
it("Set the number to 5, initializing if necessary", async () => {
const wallet = anchor.workspace.Batch.provider.wallet.payer;
const [pda, _bump] = anchor.web3.PublicKey.findProgramAddressSync([], program.programId);
let accountInfo = await anchor.getProvider().connection.getAccountInfo(pda);
let transaction = new anchor.web3.Transaction();
if (accountInfo == null || accountInfo.lamports == 0 || accountInfo.owner == anchor.web3.SystemProgram.programId) {
console.log("need to initialize");
const initTx = await program.methods.initialize().accounts({pda: pda}).transaction();
transaction.add(initTx);
}
else {
console.log("no need to initialize");
}
// we're going to set the number anyway
const setTx = await program.methods.set(5).accounts({pda: pda}).transaction();
transaction.add(setTx);
await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [wallet]);
const pdaAcc = await program.account.pda.fetch(pda);
console.log(pdaAcc.value);
});
});
We also need to modify our Rust code to not forcibly fail on the set
operation.
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE");
#[program]
pub mod batch {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
pub fn set(ctx: Context<Set>, new_val: u32) -> Result<()> {
ctx.accounts.pda.value = new_val;
Ok(()) // ERROR HAS BEEN REMOVED
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = signer, space = size_of::<PDA>() + 8, seeds = [], bump)]
pub pda: Account<'info, PDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Set<'info> {
#[account(mut)]
pub pda: Account<'info, PDA>,
}
#[account]
pub struct PDA {
pub value: u32,
}
If we run the test twice against the same local validator instance, we will get the following outputs:
First test run:
Second test run:
How does Solana deploy programs over 1232 bytes large?
If you create a new Solana program and run anchor deploy
(or anchor test
) you will see in the logs there are numerous transactions to the BFPLoaderUpgradeable
:
Transaction executed in slot 65695:
Signature: 62Zu3NPyjjaEoH4XSc7kULtuoszLPctM1PTrLiC7A3CiaGJEzYscQ5c9SKbN3UUoqctyrdzW2upDXnSC4VnMjyfZ
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65695:
Signature: 3cD19SGmdfd991NjcGHpYcnjhZ3FYqEWnHMJALQ95X5fvwHVhB3Cw9PwqSDwziiCMQHcZ8iuxXqg3UDJmp7gJHd3
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65695:
Signature: 5apuTjqCMKGdyYGRZ9sCLDapPCKqjyJMyqWMC24EsW4pLzHhM3YUgnf5Q2sqXSLVTxjKaSgZ3fcCkZrAah32uzh2
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65695:
Signature: HJ8XaErydn8ojxaEknZsg43pGA9mC8TBqV4zwSrZgXFvi5UqgZjNU65TQKqb6DyEZFtHecytt1k7U4N9Vw52rur
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65695:
Signature: 3uY9beX23VdRXeEqUSP4cpAuTevdcjHDZ8K3pwKVpw51mwX1jLGQ7LYB7d68dWSe571TeAoxq33eoUU7c8gTDgic
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65695:
Signature: 666r5LcQaH1ZcZWhrHFUFEqjHXEE1QUyh27HFRkWsDQihM7FYtyz3v4eJgVkQwhJuMDSYHJZHDRrSsNVbCFrEkV9
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65696:
Signature: 2QmPZFkDN9WsKiNjHFdaNLuaYbQFXtN8yRgHTDC3Ce2z28483LNVyuE1AnwgsRisiKeiKe5Wu9WTbkTbAwmodPTC
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65696:
Signature: EsTiuCn6PGA158Xi43XwGtYf2tDJTbgxRJehHS9AQ9AcW4qraxWuNPzdD7Wk4yeL65oaaa1G8WMqkjYbJcGzhv1V
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65696:
Signature: 3PZSv4dnggW52C3FL9E1JPvwueBp7E342o9aM29mH2CnfGsGLDBRJcN64EQeJEkc57hgGyZsiz8J1fSV1Qquz8zx
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65696:
Signature: 4ynMY9ioELf4xxtBpHeM1q2fuWM5usa1w8dXQhLhjstR8U6LmpYHTJs7Gc82XkVyMXywPrsbu3EDCAcpoFj7qwkJ
Status: Ok
Log Messages:
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 65698:
Signature: 5rs38HHbWF2ZrsgDCux1X9FRvkrhTdrEimdhidd2EYbaeezAmy9Tv5AFULgsarPtJCft8uZmsvhpYKwHGxnLf2sG
Status: Ok
Log Messages:
Program 11111111111111111111111111111111 invoke [1]
Program 11111111111111111111111111111111 success
Program BFPLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Deployed program Ao9LdZtHdMAzrFUEfRNbKEb5H4nXvpRZC69kxeAGbTPE
Program BFPLoaderUpgradeab1e11111111111111111111111 success
Here, Anchor is splitting up deploying the bytecode into multiple transactions because deploying the entire bytecode at once won’t fit in a single transaction. We can see how many transactions it took by directing the logs into the file and counting the number of transactions that happened:
solana logs > logs.txt
# run `anchor deploy` in another shell
grep "Transaction executed" logs.txt | wc -l
This will roughly match what temporarily appears after the anchor test
or anchor deploy
command:
The exact process for how the transactions are batched up are described in Solana’s Documentation: How solana program deploy works.
The list of transactions are separate transactions, not a batched transaction. If it were batched, it would exceed the 1232 byte limit.
Learn more with RareSkills
See our Solana dev course for more Solana tutorials.
Originally Published March, 10, 2024