Research
5 min read

Understanding Locking Patterns in Bundle Execution in Jito-Solana

Written by
Eclipse Labs
Published on
February 11, 2025

Introduction

In our previous article on How Jito Works, we explored how Jito optimizes block production on Solana by bundling transactions—often for MEV (Maximal Extractable Value) opportunities or for guaranteeing atomic execution. Jito’s approach to building blocks can reorder transactions or group them into bundles to yield higher throughput and more advanced features than Solana’s default scheduling. 

But how does Jito keep these bundles safe from outside interference or partial overwrites?

Solana’s account-based concurrency model is efficient in some ways: each transaction declares which accounts it will read and write. The Solana runtime then prevents conflicting transactions from modifying the same accounts at the same time, thereby ensuring each transaction sees a consistent state. When you introduce Jito’s bundles into the mix—where multiple transactions in a batch must succeed together or revert together—you need an additional layer of locks to maintain atomicity and guarantee that external transactions don’t interfere mid-bundle.

Below, we break down precisely why these locks are needed, how they’re implemented by Jito’s BundleAccountLocker, and use a detailed scenario with five hypothetical transactions to show when parallelism is possible—and when it’s not.

Key Takeaways

  • Bundle-Level Account Locking
    Jito aggregates read/write sets for all transactions in a bundle. These accounts remain locked until the entire bundle completes, guaranteeing atomicity against outside interference.
  • Parallel Execution
    Bundles (or transactions) that do not overlap in (w, w), (w, r), or (r, w) patterns can run in parallel (though Jito’s current implementation is single threaded now). For example, if Bundle #1 only writes to AccountA, and Bundle #2 only writes to AccountB, there’s no conflict—both can proceed simultaneously, boosting overall throughput.
  • Implementation
    The BundleAccountLocker module in Jito-Solana repo manages locking and unlocking behind the scenes, integrating with the Solana runtime’s per-transaction model but extending it to handle entire bundles as an atomic unit.

Why Lock Accounts for Bundles?

On Solana, each transaction includes a list of accounts it will read/write. During normal block production, the Solana runtime automatically locks those accounts. This prevents other transactions from writing to them simultaneously (or reading them if they also require write locks), ensuring atomic execution.

With Jito:

  • We want to process bundles atomically: groups of transactions that must either all succeed or all fail.
  • If a transaction in the bundle modifies a shared account, we cannot allow “outside” transactions to slip in partway through—doing so could break the atomic property or reorder states in a way that the bundler wasn’t expecting.
  • Jito’s BundleStage can run concurrently with the normal Solana “BankingStage,” so we must ensure that no partial writes or ephemeral states from the bundle leak out.

The entire set of accounts touched by a bundle must be fully locked during that bundle’s execution. Solana’s per-transaction account locks aren’t enough in themselves, because Jito might have multiple intra-bundle transactions that share writes among themselves. We need a single, higher-level lock that aggregates all accounts for the entire bundle.

An Example: “{ A, B }, { B, C }, { C, D }”

Consider a simplified example of a three-transaction bundle that shifts some data from accounts A → B → C → D. Each transaction updates the next pair of accounts. Even though each transaction by itself only modifies two accounts at a time, the entire chain of accounts {A, B, C, D} must be locked at once for the duration of the bundle. If we allowed an outside transaction to come in and modify B after the first step, the final state might no longer be correct. Without the bundle account synchronization, the block producer can produce blocks that are rejected by the network.

The BundleAccountLocker at a Glance

Jito-Solana introduces a data structure called BundleAccountLocker. When Jito receives a SanitizedBundle of transactions:

  • It inspects all read/write sets for the bundle’s transactions.
  • It aggregates these into a single “read lock set” and a single “write lock set.”
  • It then locks all these accounts in one atomic operation.
  • The bundle executes. If any transaction in the bundle fails, the entire set reverts.
  • Finally, once the bundle is done, the account locker unlocks all those accounts.

Key Methods

  • prepare_locked_bundle(...):
    • Takes a SanitizedBundle and a pointer to the Solana Bank.
    • Computes which accounts must be locked (both read and write).
    • Acquires those locks atomically and returns a “locked handle” (a LockedBundle struct).
  • unlock_bundle_accounts(...):
    • Automatically called when the LockedBundle goes out of scope (via Rust’s Drop trait).
    • Frees the locked accounts so that subsequent transactions or bundles can acquire them.

Below is an abridged snippet (from bundle_account_locker.rs) showing how locks are acquired:

#[derive(Default, Clone)]
pub struct BundleAccountLocker {
    account_locks: Arc<Mutex<BundleAccountLocks>>,
}

impl BundleAccountLocker {
    pub fn prepare_locked_bundle<'a, 'b>(
        &'a self,
        sanitized_bundle: &'b SanitizedBundle,
        bank: &Arc<Bank>,
    ) -> BundleAccountLockerResult<LockedBundle<'a, 'b>> {
        let (read_locks, write_locks) = Self::get_read_write_locks(sanitized_bundle, bank)?;
        
        // Acquire all relevant locks
        self.account_locks
            .lock()
            .unwrap()
            .lock_accounts(read_locks, write_locks);

        Ok(LockedBundle::new(self, sanitized_bundle, bank))
    }
    
    // ...
}

How Account Locks Are Determined

Because Solana already requires every transaction to specify all accounts it might touch, the BundleAccountLocker simply aggregates these declared reads and writes across all transactions in the bundle.

For instance, if one transaction is (Pubkey A: Write), another is (Pubkey B: Write), and a third has (Pubkey A: Read), the locker merges them into:

  • Write locks: {A, B}
  • Read locks: (any other read-only accounts declared across all transactions)

Below is a simplified version of how Jito’s code does this inside get_read_write_locks(...):

1fn get_read_write_locks(
2    bundle: &SanitizedBundle,
3    bank: &Bank,
4) -> BundleAccountLockerResult<(HashMap<Pubkey, u64>, HashMap<Pubkey, u64>)> {
5    let transaction_locks: Vec<TransactionAccountLocks> = bundle
6        .transactions
7        .iter()
8        .filter_map(|tx| tx.get_account_locks(bank.get_transaction_account_lock_limit()).ok())
9        .collect();
10
11    // If we can't parse locks for any transaction => error
12    if transaction_locks.len() != bundle.transactions.len() {
13        return Err(BundleAccountLockerError::LockingError);
14    }
15
16    // Count read-locations
17    let bundle_read_locks = transaction_locks
18        .iter()
19        .flat_map(|tx| tx.readonly.iter().map(|a| **a))
20        .fold(HashMap::new(), |mut map, acc| {
21            *map.entry(acc).or_insert(0) += 1;
22            map
23        });
24
25    // Count write-locations
26    let bundle_write_locks = transaction_locks
27        .iter()
28        .flat_map(|tx| tx.writable.iter().map(|a| **a))
29        .fold(HashMap::new(), |mut map, acc| {
30            *map.entry(acc).or_insert(0) += 1;
31            map
32        });
33
34    Ok((bundle_read_locks, bundle_write_locks))
35}

A HashMap<Pubkey, u64> is used because multiple transactions might read or write the same account within the bundle, so we track how many times each pubkey is used. Once these sets are built, the locker attempts to acquire them in a single atomic step.

A Full Example: Five Transactions

Let’s solidify the concept with an example involving five hypothetical transactions (Tx1..Tx5). Each of them accesses a few accounts:

  1. Tx1
    • Reads UserWalletA (R)
    • Writes DEXPool (W)
    • Reads SystemProgram (R)
  2. Tx2
    • Reads UserWalletB (R)
    • Writes TokenAccountB (W)
  3. Tx3
    • Writes DEXPool (W)
    • Reads TokenAccountA (R)
  4. Tx4
    • Reads DEXPool (R)
  5. Tx5
    • Writes UserWalletA (W)
    • Reads TokenAccountA (R)
    • Writes DEXPool (W)

Checking for Overlaps

  • Tx2 is completely disjoint from the others. It involves only UserWalletB and TokenAccountB. It doesn’t touch DEXPool or UserWalletA. This means Tx2 can run in a separate “auction” (or separate bundle) in parallel without conflict.
  • Tx1, Tx3, Tx4, and Tx5 all use DEXPool:
    • Tx1 & Tx3: both write to DEXPool → (w, w)
    • Tx1 & Tx4: (w, r) on the same account
    • Tx3 & Tx5: (w, w) on the same account
    • Tx4 & Tx5: (r, w)
    • Any presence of a write means you cannot run them in parallel. They must be serialized in one atomic block or path.

So if you tried to create a single bundle with Tx1, Tx3, Tx4, and Tx5, the aggregator would find that DEXPool is in the “write” set (because of Tx1, Tx3, Tx5). Even though Tx4 only wants read access, the presence of a write from the others means we must lock DEXPool for writing. This enforces that they all execute in one single locked sequence.

Meanwhile, Tx2 can form a separate bundle or run in a separate parallel path. No conflict arises because it doesn't share any accounts with the DEX transactions.

Let us visualize this example through a sequence diagram illustrating how the five transactions (Tx1..Tx5) interact with a hypothetical Jito Locker (i.e., the BundleAccountLocker) to acquire and release account locks. This helps understand which locks each transaction needs and when parallel execution is possible (as with Tx2), versus when transactions must wait for a shared write lock (Tx1, Tx3, Tx4, Tx5 with DEXPool).

Visualizing the Example

How to Read This Sequence Diagram

  • Tx1 requests a write lock on DEXPool (and a read lock on UserWalletA) from the Locker.
  • Tx2, in parallel, requests a write lock on TokenAccountB (reading UserWalletB), which does not conflict with DEXPool. Hence, Tx2 can start executing right away alongside Tx1.
  • Once Tx1 completes, it releases its locks on DEXPool and UserWalletA, allowing Tx3 (which also writes DEXPool) to proceed.
  • Tx4, which only needs a read lock on DEXPool, must still wait until Tx3’s write completes.
  • Finally, Tx5 requires writes to both DEXPool and UserWalletA. It cannot acquire the DEXPool lock until Tx4 finishes its read lock.

This visualization clarifies why transactions that share a write to the same account (e.g., DEXPool) must execute one after another, while transactions with disjoint accounts (like Tx2) can happen in parallel.

Holding Locks for the Entire Bundle

One subtlety is that all read/write locks remain held for the entire duration of bundle execution—not just per transaction. This means if Tx1 needs DEXPool (W), and Tx4 also references DEXPool (R), DEXPool will remain locked from the moment the bundle starts until the last transaction is finished—even if Tx4 doesn’t run until the end.

Below is how the locker releases everything only after the LockedBundle is dropped:

1impl<'a, 'b> LockedBundle<'a, 'b> {
2    fn drop(&mut self) {
3        // Automatic unlock when the LockedBundle goes out of scope
4        let _ = self
5            .bundle_account_locker
6            .unlock_bundle_accounts(self.sanitized_bundle, &self.bank);
7    }
8}

Bundle vs. Transaction-Level Parallelism

One question that often comes up in Jito discussions is whether the references to “parallel execution” apply at the bundle level or the transaction level. The short answer is: bundles are the primary concurrency boundary in Jito, not individual transactions within a bundle.

Bundles in Parallel

When two bundles do not share any conflicting read/write accounts, they can be run in parallel, though they are single threaded now. For instance, if Bundle A writes to AccountA and Bundle B writes to AccountB, the BundleAccountLocker sees no overlaps in (w, w) or (r, w) patterns across those two bundles. As a result, both bundles can safely execute side-by-side in the same slot.

Transactions Inside a Bundle

Inside a single bundle, however, Jito enforces an “all-or-nothing” guarantee across all transactions in that bundle. Since the bundle aggregates every account that any transaction might touch, Jito locks them for the entire duration. Even if certain transactions in the bundle do not conflict with each other, they are effectively grouped under one big lock. This ensures no external transaction (from another bundle or from the network) sneaks in between them, which could break the bundle’s atomicity requirement.

Does This Make Solana Sequential?

In practice, Jito doesn’t make Solana fully sequential. However, transactions (or bundles) that modify the same “hot” accounts (like a popular DEX pool) naturally end up serialized—even under default Solana rules—because they must acquire the same write lock.

What Jito does is preserve Solana’s concurrency whenever possible, while adding atomic guarantees at the bundle level. If a pair of bundles truly have disjoint read/write sets, they can run simultaneously, just like two normal Solana transactions that don’t share accounts.

This balances:

  • High throughput when bundles have no overlapping accounts,
  • Atomic safety when a bundle’s transactions must run as one unit,
  • Seamless integration with Solana’s existing per-transaction concurrency model.

Parallel Bundles in Action: A Testing Example

Below is a simplified test from bundle_account_locker.rs (abridged for clarity). It demonstrates that if two bundles write to different user accounts, they can be locked and executed simultaneously:

1#[test]
2fn test_simple_lock_bundles() {
3    // 1. Setup a minimal Bank with a minted keypair
4    let (bank, ...) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config);
5    let locker = BundleAccountLocker::default();
6
7    // 2. Build two transactions that write to different accounts (kp0, kp1)
8    let tx0 = VersionedTransaction::from(transfer(&mint_keypair, &kp0.pubkey(), 1, ...));
9    let tx1 = VersionedTransaction::from(transfer(&mint_keypair, &kp1.pubkey(), 1, ...));
10
11    // 3. Construct two sanitized bundles
12    let sanitized_bundle0 = ...;
13    let sanitized_bundle1 = ...;
14
15    // 4. Prepare locked bundle #0 => locks {mint_keypair, kp0}
16    let locked_bundle0 = locker.prepare_locked_bundle(&sanitized_bundle0, &bank).unwrap();
17    assert!(locker.write_locks().contains(&kp0.pubkey()));
18
19    // 5. Prepare locked bundle #1 => tries to lock {mint_keypair, kp1}
20    let locked_bundle1 = locker.prepare_locked_bundle(&sanitized_bundle1, &bank).unwrap();
21    assert!(locker.write_locks().contains(&kp1.pubkey()));
22
23    // 6. Drop locked_bundle0 => unlocks kp0
24    drop(locked_bundle0);
25    assert!(!locker.write_locks().contains(&kp0.pubkey()));
26    assert!(locker.write_locks().contains(&kp1.pubkey()));
27
28    // 7. Drop locked_bundle1 => unlocks kp1
29    drop(locked_bundle1);
30    assert!(locker.write_locks().is_empty());
31}

Because these two transactions write to different accounts, they can be executed in parallel or at least locked simultaneously. The moment one bundle finishes and is dropped, the relevant account locks are freed.

Edge Cases & Nuances

Cross-Program Invocations (CPI)

  • In Solana, the transaction must declare all the accounts that it will potentially write to (even inside CPIs). If a transaction might write to an account, it is included in the “writable” list. Jito then locks that account for the entire bundle.

SPL Token Transfers

  • If a transaction is sending tokens, the user’s token account is definitely “writable.” Even if your dApp logic thinks it’s “just reading” the user’s balance, in reality, the runtime sees it as a write. That’s how Jito picks it up as (W).

Performance Bottlenecks

  • If multiple transactions share a single large liquidity pool account (a DEX), they’re effectively forced into a single serialized chain of writes. This is a concurrency bottleneck. Jito can’t magically run them all in parallel if they all write the same account.

All-or-Nothing

  • If one transaction in a Jito bundle fails, the entire set reverts, unlocking everything. This ensures no partial changes are left behind.

Conclusions

Enabling safe concurrency for advanced transaction bundling is a fundamental principle of how Jito brings atomic MEV optimizations to Solana. By locking all read/write accounts for each bundle before execution starts, Jito guarantees:

  • No partial states slip in from outside.
  • A failing transaction within the bundle reverts the entire atomic batch.
  • Non-conflicting bundles (or single transactions) can run in parallel, preserving Solana’s high throughput.

This architecture neatly balances the need for concurrency (higher transaction throughput) with the requirement that certain transactions must remain serialized if they write the same account. However, there is still room for improvement—future research or enhancements could explore finer-grained locking strategies to reduce contention on “hot” accounts. Such refinements might allow even more concurrency in scenarios where multiple transactions within a bundle or across bundles partially overlap on large accounts.

References

Share this post