Summary
In this blog, you learn:
- The Solana transaction size limitation
- Jito bundles, what they are, and why we need them
- A practical example with TS on how to build and submit a Jito bundler to the network (Github repo)
Introduction
Solana is a fast, cheap, and performant network; it has a very high TPS, the best of all other chains. But we are not living in a perfect world; everything comes with a price. When you make something that good and cheap, tradeoffs are inevitable. One of these tradeoffs is the transaction size limitation.
Solana transaction size limitation
Solana network enforces a 1232-byte transaction size limit to ensure each transaction fits into a single network packet. This design choice is deliberate and efficient. Solana network adheres to a maximum transmission unit (MTU) size of 1280 bytes, consistent with the IPv6 MTU, and after accounting for the necessary headers (40 bytes for IPv6 and 8 bytes for the fragment header), 1232 bytes remain available for packet data. You can read more about this here
While this is sufficient for most use cases, there are times when you might need more. And if, for some reason, you want to have two transactions, and you want one of them to get submitted before the other, and you want to make sure that the second transaction never reaches the network if the first one has some issues,
You can't sign and send both transactions simultaneously because there's no guarantee the first transaction will be submitted first. Instead, you must sign and submit the first transaction, wait for confirmation, then sign and submit the second. This process creates a poor user experience, as users must sign twice for a single action. So, what's the solution? Here comes Jito Bundles
Jito
Jito is one of the biggest projects on the Solana network; they do a lot of cool things (check their blog), but we are now interested in the Jito Bundles feature. Jito Bundles consist of up to five transactions that execute sequentially and atomically, ensuring an all-or-nothing outcome.
How do Bundles work?
- Traders submit bundles to block engines
- Block engines simulate bundles to determine the most profitable combinations
- Winning bundles are sent to validators to include in blocks
- Validators execute bundles atomically and collect tips
Practical Example
In this example, you will build and submit a bundle to Jito. You can find the example code in this GitHub repo
Similar to Solana's priority fees, Jito employs a mechanism to prioritize bundles called 'Jito tips.' These user-defined amounts incentivize validators to process bundles ahead of other transactions, ensuring faster execution.
Tips are simply a SOL transfer instruction to one of the known Jito tip accounts, and this instruction should be present in your bundle either as a part of another transaction or as a stand-alone transaction, we will look at both of them here.
If you go ahead and clone the repo, open the readme file and follow the instructions to install the dependencies (I use a bun, but feel free to use something else) and set up the env variables, then you can find two files inside the src
folder, embedded-tip-ix.ts
and separated-tip-tx.ts,
they are pretty similar, the only change is that we are embedding the tip instruction in one of the bundle's transactions (the last one) in one of them, while we are separating it into a standalone transaction in the other one, you can read more about the best practices of tipping here and here
TL;DR: you should try to have the tipping instruction as the last one in the bundle, and it is better to have it inside a transaction and not a stand-alone transaction, in this case, you will make sure that your tip will not go for wast by a malicious validator or an uncle block
Now let's take a look at the file src/embedded-tip-ix.ts
and skip the import part (because it is boring), the first function that you will see there is getRandomeTipAccountAddress
const getRandomeTipAccountAddress = async (
searcherClient: searcher.SearcherClient,
) => {
const account = await searcherClient.getTipAccounts();
return new PublicKey(account[Math.floor(Math.random() * account.length)]);
};
This function fetches the tip accounts and selects a random one. Using a random account instead of always selecting the first one increases the chance of landing our bundle, if too many transactions try to tip the same account, that might push some of them to fail.
The very next thing is the buildMemoTransaction
:
const MEMO_PROGRAM_ID = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo";
const buildMemoTransaction = (
keypair: Keypair,
message: string,
recentBlockhash: string,
tipIx?: TransactionInstruction,
): VersionedTransaction => {
const ix = new TransactionInstruction({
keys: [
{
pubkey: keypair.publicKey,
isSigner: true,
isWritable: true,
},
],
programId: new PublicKey(MEMO_PROGRAM_ID),
data: Buffer.from(message),
});
const instructions = [ix];
if (tipIx) instructions.push(tipIx);
const messageV0 = new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: recentBlockhash,
instructions,
}).compileToV0Message();
const tx = new VersionedTransaction(messageV0);
tx.sign([keypair]);
console.log("txn signature is: ", bs58.encode(tx.signatures[0]));
return tx;
};
Just a simple method that will build a memo transaction, which has no use, but it is so cheap and simple, so it is perfect for our example since we are going to do this on the main net, it also takes an optional tip instruction and pushes it in the transaction if provided.
Sitting that aside, let's get to the meat of this, the first few lines of the main
function is just to setup few variables.
const main = async () => {
const blockEngineUrl = process.env.BLOCK_ENGINE_URL || "";
console.log("BLOCK_ENGINE_URL:", blockEngineUrl);
const authKeypairPath = process.env.AUTH_KEYPAIR_PATH || "";
console.log("AUTH_KEYPAIR_PATH:", authKeypairPath);
const decodedKey = new Uint8Array(
JSON.parse(Fs.readFileSync(authKeypairPath).toString()) as number[],
);
const keypair = Keypair.fromSecretKey(decodedKey);
const bundleTransactionLimit = parseInt(
process.env.BUNDLE_TRANSACTION_LIMIT || "5",
);
After that, we need to create the searcher client, which is what we will use to communicate with the Jito server
// Create the searcher client that will interact with Jito
const searcherClient = searcher.searcherClient(blockEngineUrl);
And we can subscribe to the onBundleRequest
so we get updates about our bundle once we send it to the network
// Subscribe to the bundle result
searcherClient.onBundleResult(
(result) => {
console.log("received bundle result:", result);
},
(e) => {
throw e;
},
);
Then we will get the tip account and build the tip instruction
// Get a random tip account address
const tipAccount = await getRandomeTipAccountAddress(searcherClient);
console.log("tip account:", tipAccount);
const rpcUrl = process.env.RPC_URL || "";
console.log("RPC_URL:", rpcUrl);
// get the latest blockhash
const connection = new Connection(rpcUrl, "confirmed");
const blockHash = await connection.getLatestBlockhash();
// Build a Transfer Instruction
const tipIx = SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: tipAccount,
lamports: 1000,
});
As mentioned earlier, the tip can be included in one of the bundle transactions. Alternatively, it can be a stand-alone transaction.
const transactions = [
buildMemoTransaction(keypair, "jito test 1", blockHash.blockhash),
// Include the tip instruction in the second transactions
buildMemoTransaction(keypair, "jito test 2", blockHash.blockhash, tipIx),
];
but we can also have it as a stand-alone transaction like this
const tipTx = new VersionedTransaction(
new TransactionMessage({
payerKey: keypair.publicKey,
recentBlockhash: blockHash.blockhash,
instructions: [tipIx],
}).compileToV0Message(),
);
tipTx.sign([keypair]);
const transactions = [
buildMemoTransaction(keypair, "jito test 1", blockHash.blockhash),
buildMemoTransaction(keypair, "jito test 2", blockHash.blockhash),
];
With everything in place, we can now build the bundle using the transactions and submit it to Jito.
const jitoBundle = new bundle.Bundle(
[...transactions, tipTx],
bundleTransactionLimit,
);
try {
const resp = await searcherClient.sendBundle(jitoBundle);
console.log("resp:", resp);
} catch (e) {
console.error("error sending bundle:", e);
}
here is the full file from the example src/embedded-tip-ix.ts
link
Now if you run that file, you should see the transaction signatures and the bundle Id, you can check the transactions in any explorer and you can check the bundle id in Jito Explorer (it might take a minute before showing up there)
txn signature is: 5YQVsedCaaf1bCbTUJgd23vNfte2doB1K3CB6tKxCH7KYY7Y8rDwbmetCcgBufhz8nY1nWDeCRNqhUkWNydnsjeZ
txn signature is: 3mnhGK2X2FVnsada8YL46TYdZc7BfR14GdYTjJWk8mrG2WzBJGXdyN7aLVNP3ZkWGwUBpGypjW7JWFeYGKNRa2vR
resp: 09d2c693a232d48781f69d786276b8af04be9138b0777d313b18251271825b3c
Congrats, you have submitted a bundle
Conclusion
Jito Bundles provide a powerful solution for handling Solana’s transaction size limitations, ensuring seamless and atomic execution of complex operations. By leveraging tips and smart transaction bundling, developers can optimize the user experience and make the most of Solana’s capabilities. With this guide and the provided example, you now have the tools to integrate Jito Bundles into your Solana projects effectively. Try it out and explore the possibilities!
Top comments (0)