Your mint opens, or a user confirms a large swap.
The transaction with a fat priority fee still lands two slots late, or inside someone else's sandwich.
The fee bought a place in the queue, not an outcome.
A bundle is different: up to 5 transactions run in order, in one slot, or not at all.
This is a runbook for shipping bundles from a production app.
Five steps: endpoints, tip math, a 30s status poll, and the three failures that have no error string.
Step 1: Decide When a Bundle Beats a Priority Fee
A priority fee raises the odds that the current leader picks your transaction. It says nothing about what runs before or after it. A failed transaction still pays the fee.
A bundle changes three things, per the block engine docs. Transactions run in the order you list them. They run inside one slot, and a bundle "cannot cross slot boundaries." They're all-or-nothing, so if one fails, none commit.
Reach for a bundle when at least one of these is true:
- Two or more transactions must land together or not at all (swap plus settle, buy plus list).
- A front-run or sandwich would cost more than the tip (large swaps, launches, liquidations).
- A reverted transaction would still bill a fee you'd rather not pay.
Stick with a priority fee when you have one small transaction, no ordering constraint, and no attacker worth worrying about. Bundles cost a tip on top of the base fee. They only land when the slot leader runs the modified validator client. We'll come back to that in the failure section.
There's a middle path. The single-transaction endpoint accepts one transaction and, with the bundle-only flag, wraps it as a one-transaction bundle. You get revert protection without the full bundle flow, under the same tip rules.
Step 2: Endpoints, Tip Accounts, and Rate Limits
Everything below comes from the block engine docs, fetched the day we wrote this. Check them again before you ship. The list moves.
Global mainnet: https://mainnet.block-engine.jito.wtf
Regional: https://<region>.mainnet.block-engine.jito.wtf
Regions listed: amsterdam, dublin, frankfurt, london, ny, slc, singapore, tokyo
Bundle RPC: POST /api/v1/bundles (JSON-RPC 2.0)
Methods: getTipAccounts, sendBundle, getBundleStatuses, getInflightBundleStatuses
Single tx: POST /api/v1/transactions?bundleOnly=true
Tip floor: https://bundles.jito.wtf/api/v1/bundles/tip_floor
Tip stream: wss://bundles.jito.wtf/api/v1/bundles/tip_streamPick the region closest to your server, not to your users. The docs also list newer per-method paths, such as one path per status method.
Three constraints shape the code in the next steps:
- A bundle holds at most 5 fully signed transactions, base64 encoded. Base58 is deprecated and slower.
- Every bundle needs a tip of at least 1,000 lamports, sent as a SOL transfer to one of 8 tip accounts. The tip can sit in any instruction, top-level or CPI.
- The default rate limit is 1 request per second per IP per region. Go over it and you get a 429.
Fetch the tip accounts at startup and pick one at random per bundle. The docs recommend that to spread write-lock contention. Two traps follow. Don't put the tip account in an Address Lookup Table (the docs say so in plain words). And don't pass an auth key pair unless the team gave you one, because the "not authorized to generate a token" error is almost always a key you didn't need.
Step 3: Size the Tip From the Tip Floor
Tips are an auction. The tip floor endpoint above reports what recently landed bundles paid, with a stream for live updates and a chart at explorer.jito.wtf/tip-floor.
The REST response is an array with one object per time window. Each carries the p25 through p99 of landed tips plus a p50 moving average, as fractions of SOL. On 2026-10-27 the tip floor endpoint reported a p50 of about 0.0002 SOL and a p95 of about 0.0018 SOL. Yours will differ.
The code targets the @solana/web3.js 1.x line, at 1.99.0, on Node 18 or newer for the built-in fetch. The package's latest tag is 3.1.1, a different API. We stay on 1.x because jito-ts 4.2.1 depends on web3.js in the 1.x range, so the SDK's types line up if you add it later.
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
const TIP_FLOOR_URL = "https://bundles.jito.wtf/api/v1/bundles/tip_floor";
const MIN_TIP_LAMPORTS = 1_000; // protocol minimum per docs.jito.wtf
type TipFloor = {
landed_tips_50th_percentile: number;
landed_tips_75th_percentile: number;
landed_tips_95th_percentile: number;
};
export async function tipLamports(
urgency: "normal" | "high" | "critical",
capLamports: number,
): Promise<number> {
const res = await fetch(TIP_FLOOR_URL);
const [floor] = (await res.json()) as TipFloor[];
const pick =
urgency === "critical"
? floor.landed_tips_95th_percentile
: urgency === "high"
? floor.landed_tips_75th_percentile
: floor.landed_tips_50th_percentile;
const lamports = Math.ceil(pick * LAMPORTS_PER_SOL);
// Never below the protocol minimum, never above what the action is worth.
return Math.min(Math.max(lamports, MIN_TIP_LAMPORTS), capLamports);
}Set the cap from the business, not from the network. For a user swap, a fixed fraction of the trade value works. For an internal job like a snapshot airdrop, a per-run budget works. If the floor rises above the cap, you'll find out from the status poll rather than by overpaying.
Step 4: Build the Bundle: Atomic, Ordered, Max Five
Build each transaction as you normally would. Share one recent block hash across the bundle, then sign every transaction before you serialize. Put the tip instruction inside the transaction you're protecting. The docs recommend that placement. A standalone tip transaction can be replayed on its own and paid for nothing, which raises your exposure to uncle bandit scenarios. If the protected transaction is out of space, the docs' fallback is a separate tip transaction that carries assertions on the expected state.
import {
Keypair,
PublicKey,
SystemProgram,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
export function buildBundle(
payer: Keypair,
blockhash: string,
steps: TransactionInstruction[][], // one inner array per transaction
tipAccount: PublicKey,
tip: number,
tipIn = steps.length - 1, // index of the tx that carries the tip
): string[] {
if (steps.length > 5) throw new Error("max 5 txs per bundle");
const tipIx = SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: tipAccount,
lamports: tip,
});
// The tip rides inside the protected transaction, as the docs recommend.
const withTip = steps.map((ixs, i) => (i === tipIn ? [...ixs, tipIx] : ixs));
return withTip.map((instructions) => {
const msg = new TransactionMessage({
payerKey: payer.publicKey,
recentBlockhash: blockhash,
instructions,
}).compileToV0Message();
const tx = new VersionedTransaction(msg);
tx.sign([payer]);
return Buffer.from(tx.serialize()).toString("base64");
});
}If a front-run is the main worry, add the sandwich marker. Include any valid public key that starts with jitodontfront as an account on an instruction. The block engine then rejects any bundle where that transaction isn't at index 0. The account doesn't need to exist on chain, and the marker works on the single-transaction endpoint too.
Simulate each transaction with your normal RPC before you bundle. A bundle that fails simulation will fail on chain. You'll have paid nothing and learned nothing.
Step 5: Send It and Poll the Status
A successful send returns a bundle ID, the SHA-256 of the transaction signatures. The docs are blunt that success here "does not guarantee the bundle will be processed or land on-chain." Treat the ID as a receipt, then poll.
const BE = "https://mainnet.block-engine.jito.wtf/api/v1/bundles";
async function rpc<T>(method: string, params: unknown[]): Promise<T> {
const res = await fetch(BE, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
if (res.status === 429) throw new Error("rate limited: 1 rps per IP per region");
const json = await res.json();
if (json.error) throw new Error(json.error.message);
return json.result as T;
}
type Inflight = {
bundle_id: string;
status: "Invalid" | "Pending" | "Failed" | "Landed";
landed_slot: number | null;
};
export async function sendAndWait(txs: string[], timeoutMs = 30_000) {
const bundleId = await rpc<string>("sendBundle", [txs, { encoding: "base64" }]);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1_100)); // stay under 1 rps
const { value } = await rpc<{ value: Inflight[] }>("getInflightBundleStatuses", [[bundleId]]);
const s = value?.[0]?.status;
if (s === "Landed") return { bundleId, slot: value[0].landed_slot };
if (s === "Failed" || s === "Invalid") throw new Error(`bundle ${s}`);
}
throw new Error("bundle expired without landing");
}The in-flight status call looks back five minutes and returns one of the four states typed in the code above. Once it reports landed, call the full status method for the slot, the signatures, and a confirmation level of processed, confirmed, or finalized. A null result there means not found or not landed yet.
We poll every 1,100 ms to stay under the 1 request per second limit. If you run several workers behind one IP, share a single poller or request a higher limit through the ticket link in the docs.
When It Doesn't Land: Dropped, Tip Too Low, Wrong Leader
These are the three failures you'll meet in production. Each needs a different response, and none come with a helpful error string.
Bundle dropped
A failed state means every region that saw the bundle marked it failed. An invalid state means the engine has no record of it. Rebuild with a fresh block hash, re-simulate, and resend. Don't resend the same bytes in a loop; the block hash ages and you burn your rate limit.
Tip too low
There's no error for this. The docs say a low tip "might not be selected during the auction," so it shows up as pending until expiry. Re-fetch the tip floor, move up a percentile, and resend with a new block hash, up to your cap.
Leader not running the client
Bundles only land when the slot leader runs the Jito-Solana client. The JSON-RPC API doesn't expose the schedule. A bundle sent during another leader's slots waits, then expires, and your poller sees pending until the deadline.
The gRPC jito-ts SDK does expose the next scheduled leader and a bundle result stream. Version 4.2.1 is the latest published release, but its package manifest pins @solana/web3.js to the 1.77 line. Weigh that dependency drag before you add it to a codebase on a newer 1.x or on 3.x.
One more edge from the docs. If the block ends up as an uncle, your transactions can be replayed one by one, without atomicity or revert protection. That's the uncle bandit case, and it's why the tip belongs inside the protected transaction: a lone tip transaction replayed alone pays for nothing. Write each instruction so it's safe to run alone.
FAQ
Does a bundle guarantee my transaction lands?
No. A successful send only means the block engine accepted it. It still has to win the tip auction and arrive while a leader running that client holds the slot, and the docs say so in as many words. Poll the in-flight status and plan for a pending state that never resolves.
How much should I tip?
Start from the live tip floor, pick the percentile that matches how urgent the bundle is, and cap it at what the action is worth. The protocol minimum is 0.000001 SOL, and the floor moves by the minute. No fixed number works across a quiet hour and a token launch.
Can I send the tip as a separate transaction instead of inside my main one?
You can, but the docs advise against it unless you're out of space. Any instruction, top-level or CPI, that transfers SOL to a tip account counts. Keeping it in the protected transaction means a failed or replayed transaction never pays the tip alone. If you must split it, add assertions to the tip transaction that check the state you expected.
What happens if one transaction in the bundle fails?
None of them commit. Bundles are all-or-nothing within the slot. The documented exception is an uncle block, where transactions can be replayed individually, so write each one to be safe on its own.
Should I use the TypeScript SDK or plain JSON-RPC?
Use JSON-RPC unless you need the gRPC-only features, such as the leader schedule or the bundle result stream. The 4.2.1 release pins an older 1.x web3.js range, which fights with a codebase on 1.99.0 or on 3.x.
Ship It With Us
The airdrop and royalty payment tools in our historical Solana Tool Suite case study send batches of transfers from one wallet. That's the shape where ordering and all-or-nothing matter. A half-finished batch is a reconciliation job nobody wants. If we were building that kind of tool today, we'd suggest the five steps above, tips capped per run, and a status log per bundle.
This is part of our Solana development service. For trading surfaces, liquidation bots, and launch flows, the same layer sits under our Solana DeFi development work. There the question is less "can we send a bundle" and more "what does the app do when it doesn't land."
Try the tip floor fetch against your own wallet first. If your transactions fail or get sandwiched at the moments that matter, we can build the submission layer, the tip policy, and the status handling into your app. Talk to us about your Solana build.



