Skip to main content

jito_bundle/bundler/
bundle.rs

1use crate::JitoError;
2use crate::analysis::TransactionAnalysis;
3use crate::constants::MAX_BUNDLE_TRANSACTIONS;
4use crate::tip::TipHelper;
5use solana_compute_budget_interface::ComputeBudgetInstruction;
6use solana_instruction::{AccountMeta, Instruction};
7use solana_pubkey::Pubkey;
8use solana_sdk::address_lookup_table::AddressLookupTableAccount;
9use solana_sdk::hash::Hash;
10use solana_sdk::message::{VersionedMessage, v0};
11use solana_sdk::signature::{Keypair, Signer};
12use solana_sdk::transaction::VersionedTransaction;
13
14pub struct Bundle<'a> {
15    pub versioned_transaction: Vec<VersionedTransaction>,
16    pub payer: &'a Keypair,
17    pub transactions_instructions: [Option<Vec<Instruction>>; 5],
18    pub lookup_tables: &'a [AddressLookupTableAccount],
19    pub recent_blockhash: Hash,
20    pub tip_lamports: u64,
21    pub jitodontfront_pubkey: Option<&'a Pubkey>,
22    pub compute_unit_limit: u32,
23    pub tip_account: Pubkey,
24    pub last_txn_is_tip: bool,
25}
26
27pub struct BundleBuilderInputs<'a> {
28    pub payer: &'a Keypair,
29    pub transactions_instructions: [Option<Vec<Instruction>>; 5],
30    pub lookup_tables: &'a [AddressLookupTableAccount],
31    pub recent_blockhash: Hash,
32    pub tip_lamports: u64,
33    pub jitodontfront_pubkey: Option<&'a Pubkey>,
34    pub compute_unit_limit: u32,
35}
36
37impl<'a> Bundle<'a> {
38    pub fn new(inputs: BundleBuilderInputs<'a>) -> Self {
39        let BundleBuilderInputs {
40            payer,
41            transactions_instructions,
42            lookup_tables,
43            recent_blockhash,
44            tip_lamports,
45            jitodontfront_pubkey,
46            compute_unit_limit,
47        } = inputs;
48        let tip_account = TipHelper::get_random_tip_account();
49        Self {
50            versioned_transaction: vec![],
51            tip_account,
52            payer,
53            transactions_instructions,
54            lookup_tables,
55            recent_blockhash,
56            tip_lamports,
57            jitodontfront_pubkey,
58            compute_unit_limit,
59            last_txn_is_tip: false,
60        }
61    }
62
63    fn populated_count(&self) -> usize {
64        self.transactions_instructions
65            .iter()
66            .filter(|slot| slot.is_some())
67            .count()
68    }
69
70    fn compact_transactions(&mut self) {
71        let mut new_slots: [Option<Vec<Instruction>>; 5] = std::array::from_fn(|_| None);
72        let mut idx = 0;
73        for slot in &mut self.transactions_instructions {
74            if let Some(ixs) = slot.take()
75                && idx < new_slots.len()
76            {
77                new_slots[idx] = Some(ixs);
78                idx += 1;
79            }
80        }
81        self.transactions_instructions = new_slots;
82    }
83
84    fn last_populated_index(&self) -> Option<usize> {
85        self.transactions_instructions
86            .iter()
87            .rposition(|slot| slot.is_some())
88    }
89
90    fn append_tip_transaction(&mut self) -> Result<(), JitoError> {
91        let tip_ix = TipHelper::create_tip_instruction_to(
92            &self.payer.pubkey(),
93            &self.tip_account,
94            self.tip_lamports,
95        );
96        let first_none = self
97            .transactions_instructions
98            .iter()
99            .position(|slot| slot.is_none())
100            .ok_or(JitoError::InvalidBundleSize {
101                count: MAX_BUNDLE_TRANSACTIONS,
102            })?;
103        self.transactions_instructions[first_none] = Some(vec![tip_ix]);
104        self.last_txn_is_tip = true;
105        Ok(())
106    }
107
108    fn append_tip_instruction(&mut self) {
109        let tip_ix = TipHelper::create_tip_instruction_to(
110            &self.payer.pubkey(),
111            &self.tip_account,
112            self.tip_lamports,
113        );
114        if let Some(last_idx) = self.last_populated_index()
115            && let Some(ixs) = &mut self.transactions_instructions[last_idx]
116        {
117            ixs.push(tip_ix);
118        }
119    }
120
121    fn apply_jitodont_front(&mut self, jitodontfront_pubkey: &Pubkey) {
122        for ixs in self.transactions_instructions.iter_mut().flatten() {
123            for instruction in ixs.iter_mut() {
124                instruction
125                    .accounts
126                    .retain(|acct| !acct.pubkey.to_string().starts_with("jitodontfront"));
127            }
128        }
129        if let Some(Some(ixs)) = self.transactions_instructions.first_mut()
130            && let Some(instruction) = ixs.first_mut()
131        {
132            instruction
133                .accounts
134                .push(AccountMeta::new_readonly(*jitodontfront_pubkey, false));
135        }
136    }
137
138    fn build_versioned_transaction(
139        &self,
140        index: usize,
141        total: usize,
142        tx_instructions: &[Instruction],
143    ) -> Result<VersionedTransaction, JitoError> {
144        let compute_budget =
145            ComputeBudgetInstruction::set_compute_unit_limit(self.compute_unit_limit);
146        let mut instructions = vec![compute_budget];
147        instructions.extend_from_slice(tx_instructions);
148
149        let lut: &[AddressLookupTableAccount] = if index == total - 1 && self.last_txn_is_tip {
150            &[]
151        } else {
152            self.lookup_tables
153        };
154
155        let message = v0::Message::try_compile(
156            &self.payer.pubkey(),
157            &instructions,
158            lut,
159            self.recent_blockhash,
160        )
161        .map_err(|e| {
162            TransactionAnalysis::log_accounts_not_in_luts(
163                &instructions,
164                lut,
165                &format!("TX: {index} COMPILE_FAIL"),
166            );
167            JitoError::MessageCompileFailed {
168                index,
169                reason: e.to_string(),
170            }
171        })?;
172        let txn = VersionedTransaction::try_new(VersionedMessage::V0(message), &[self.payer])
173            .map_err(|e| JitoError::TransactionCreationFailed {
174                index,
175                reason: e.to_string(),
176            })?;
177        let size_info = TransactionAnalysis::analyze_transaction_size(&txn);
178        if size_info.is_oversized {
179            return Err(JitoError::TransactionOversized {
180                index,
181                size: size_info.size,
182                max: size_info.max_size,
183            });
184        }
185        Ok(txn)
186    }
187
188    pub fn build(mut self) -> Result<Self, JitoError> {
189        self.compact_transactions();
190        let count = self.populated_count();
191        if count == 0 {
192            return Err(JitoError::InvalidBundleSize { count: 0 });
193        }
194
195        if let Some(jitodontfront_pubkey) = self.jitodontfront_pubkey {
196            self.apply_jitodont_front(jitodontfront_pubkey);
197        }
198
199        if count < MAX_BUNDLE_TRANSACTIONS {
200            self.append_tip_transaction()?;
201        } else {
202            self.append_tip_instruction();
203        }
204
205        let total = self.populated_count();
206        let mut versioned = Vec::with_capacity(total);
207        for (compiled_index, ixs) in self.transactions_instructions.iter().flatten().enumerate() {
208            let txn = self.build_versioned_transaction(compiled_index, total, ixs)?;
209            versioned.push(txn);
210        }
211        self.versioned_transaction = versioned;
212
213        if !self.last_txn_is_tip {
214            Self::validate_tip_not_in_luts(&self.tip_account, self.lookup_tables)?;
215        }
216
217        Ok(self)
218    }
219
220    fn validate_tip_not_in_luts(
221        tip_account: &Pubkey,
222        lookup_tables: &[AddressLookupTableAccount],
223    ) -> Result<(), JitoError> {
224        for lut in lookup_tables {
225            if lut.addresses.contains(tip_account) {
226                return Err(JitoError::TipAccountInLut {
227                    tip_account: tip_account.to_string(),
228                });
229            }
230        }
231        Ok(())
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::constants::{JITO_TIP_ACCOUNTS, SOLANA_MAX_TX_SIZE, SYSTEM_PROGRAM_ID};
239    use solana_sdk::signature::Keypair;
240
241    fn assert_build_ok(result: Result<Bundle<'_>, JitoError>) -> Bundle<'_> {
242        match result {
243            Ok(b) => b,
244            Err(e) => {
245                assert!(e.to_string().is_empty(), "build failed: {e}");
246                std::process::abort();
247            }
248        }
249    }
250
251    fn get_slot<'a>(bundle: &'a Bundle<'_>, index: usize) -> &'a Vec<Instruction> {
252        match &bundle.transactions_instructions[index] {
253            Some(ixs) => ixs,
254            None => {
255                assert!(false, "expected Some at slot {index}, got None");
256                std::process::abort();
257            }
258        }
259    }
260
261    struct TestBundleParams<'a> {
262        pub payer: &'a Keypair,
263        pub tx_count: usize,
264        pub blockhash: Hash,
265        pub luts: &'a [AddressLookupTableAccount],
266        pub jdf: Option<&'a Pubkey>,
267        pub tip: u64,
268    }
269
270    fn make_noop_instruction(payer: &Pubkey) -> Instruction {
271        let mut data = vec![2, 0, 0, 0];
272        data.extend_from_slice(&0u64.to_le_bytes());
273        Instruction {
274            program_id: SYSTEM_PROGRAM_ID,
275            accounts: vec![
276                AccountMeta::new(*payer, true),
277                AccountMeta::new(*payer, false),
278            ],
279            data,
280        }
281    }
282
283    fn make_custom_instruction(payer: &Pubkey, program_id: Pubkey) -> Instruction {
284        Instruction {
285            program_id,
286            accounts: vec![AccountMeta::new(*payer, true)],
287            data: vec![1, 2, 3],
288        }
289    }
290
291    fn make_bundle_inputs(params: TestBundleParams<'_>) -> BundleBuilderInputs<'_> {
292        let TestBundleParams {
293            payer,
294            tx_count,
295            blockhash,
296            luts,
297            jdf,
298            tip,
299        } = params;
300        let pubkey = payer.pubkey();
301        let mut slots: [Option<Vec<Instruction>>; 5] = [None, None, None, None, None];
302        for slot in slots.iter_mut().take(tx_count) {
303            *slot = Some(vec![make_noop_instruction(&pubkey)]);
304        }
305        BundleBuilderInputs {
306            payer,
307            transactions_instructions: slots,
308            lookup_tables: luts,
309            recent_blockhash: blockhash,
310            tip_lamports: tip,
311            jitodontfront_pubkey: jdf,
312            compute_unit_limit: 200_000,
313        }
314    }
315
316    #[test]
317    fn jitodontfront_added_to_first_instruction() {
318        let payer = Keypair::new();
319        let jdf = Pubkey::new_unique();
320        let inputs = make_bundle_inputs(TestBundleParams {
321            payer: &payer,
322            tx_count: 1,
323            blockhash: Hash::default(),
324            luts: &[],
325            jdf: Some(&jdf),
326            tip: 100_000,
327        });
328        let bundle = assert_build_ok(Bundle::new(inputs).build());
329        let first_tx_instructions = get_slot(&bundle, 0);
330        let first_ix = &first_tx_instructions[0];
331        let last_account = &first_ix.accounts[first_ix.accounts.len() - 1];
332        assert_eq!(last_account.pubkey, jdf);
333        assert!(!last_account.is_signer);
334        assert!(!last_account.is_writable);
335    }
336
337    #[test]
338    fn jitodontfront_none_means_no_extra_account() {
339        let payer = Keypair::new();
340        let inputs = make_bundle_inputs(TestBundleParams {
341            payer: &payer,
342            tx_count: 1,
343            blockhash: Hash::default(),
344            luts: &[],
345            jdf: None,
346            tip: 100_000,
347        });
348        let bundle = assert_build_ok(Bundle::new(inputs).build());
349        let first_ix = &get_slot(&bundle, 0)[0];
350        assert_eq!(first_ix.accounts.len(), 2);
351    }
352
353    #[test]
354    fn one_tx_produces_two_versioned_txs() {
355        let payer = Keypair::new();
356        let inputs = make_bundle_inputs(TestBundleParams {
357            payer: &payer,
358            tx_count: 1,
359            blockhash: Hash::default(),
360            luts: &[],
361            jdf: None,
362            tip: 100_000,
363        });
364        let bundle = assert_build_ok(Bundle::new(inputs).build());
365        assert_eq!(bundle.versioned_transaction.len(), 2);
366        assert!(bundle.last_txn_is_tip);
367    }
368
369    #[test]
370    fn four_txs_produce_five_versioned_txs() {
371        let payer = Keypair::new();
372        let inputs = make_bundle_inputs(TestBundleParams {
373            payer: &payer,
374            tx_count: 4,
375            blockhash: Hash::default(),
376            luts: &[],
377            jdf: None,
378            tip: 100_000,
379        });
380        let bundle = assert_build_ok(Bundle::new(inputs).build());
381        assert_eq!(bundle.versioned_transaction.len(), 5);
382        assert!(bundle.last_txn_is_tip);
383    }
384
385    #[test]
386    fn five_txs_produce_five_versioned_txs_tip_inline() {
387        let payer = Keypair::new();
388        let inputs = make_bundle_inputs(TestBundleParams {
389            payer: &payer,
390            tx_count: 5,
391            blockhash: Hash::default(),
392            luts: &[],
393            jdf: None,
394            tip: 100_000,
395        });
396        let bundle = assert_build_ok(Bundle::new(inputs).build());
397        assert_eq!(bundle.versioned_transaction.len(), 5);
398        assert!(!bundle.last_txn_is_tip);
399    }
400
401    #[test]
402    fn zero_transactions_returns_invalid_bundle_size() {
403        let payer = Keypair::new();
404        let inputs = BundleBuilderInputs {
405            payer: &payer,
406            transactions_instructions: [None, None, None, None, None],
407            lookup_tables: &[],
408            recent_blockhash: Hash::default(),
409            tip_lamports: 100_000,
410            jitodontfront_pubkey: None,
411            compute_unit_limit: 200_000,
412        };
413        let result = Bundle::new(inputs).build();
414        assert!(result.is_err());
415        let err = result.err();
416        assert!(
417            matches!(err, Some(JitoError::InvalidBundleSize { count: 0 })),
418            "expected InvalidBundleSize {{ count: 0 }}, got {err:?}"
419        );
420    }
421
422    #[test]
423    fn one_to_five_transactions_all_succeed() {
424        for tx_count in 1..=5 {
425            let payer = Keypair::new();
426            let inputs = make_bundle_inputs(TestBundleParams {
427                payer: &payer,
428                tx_count,
429                blockhash: Hash::default(),
430                luts: &[],
431                jdf: None,
432                tip: 100_000,
433            });
434            let result = Bundle::new(inputs).build();
435            assert!(result.is_ok(), "expected Ok for {tx_count} transactions");
436        }
437    }
438
439    #[test]
440    fn compiled_transactions_within_size_limit() {
441        let payer = Keypair::new();
442        let inputs = make_bundle_inputs(TestBundleParams {
443            payer: &payer,
444            tx_count: 2,
445            blockhash: Hash::default(),
446            luts: &[],
447            jdf: None,
448            tip: 100_000,
449        });
450        let bundle = assert_build_ok(Bundle::new(inputs).build());
451        for (i, tx) in bundle.versioned_transaction.iter().enumerate() {
452            let serialized = bincode::serialize(tx).unwrap_or_default();
453            assert!(
454                serialized.len() <= SOLANA_MAX_TX_SIZE,
455                "transaction {i} is {size} bytes, exceeds {SOLANA_MAX_TX_SIZE}",
456                size = serialized.len()
457            );
458        }
459    }
460
461    #[test]
462    fn oversized_transaction_returns_error() {
463        let payer = Keypair::new();
464        let pubkey = payer.pubkey();
465        let big_data = vec![0u8; 1500];
466        let big_ix = Instruction {
467            program_id: SYSTEM_PROGRAM_ID,
468            accounts: vec![AccountMeta::new(pubkey, true)],
469            data: big_data,
470        };
471        let inputs = BundleBuilderInputs {
472            payer: &payer,
473            transactions_instructions: [Some(vec![big_ix]), None, None, None, None],
474            lookup_tables: &[],
475            recent_blockhash: Hash::default(),
476            tip_lamports: 100_000,
477            jitodontfront_pubkey: None,
478            compute_unit_limit: 200_000,
479        };
480        let result = Bundle::new(inputs).build();
481        assert!(result.is_err());
482        let err = result.err();
483        assert!(
484            matches!(err, Some(JitoError::TransactionOversized { .. })),
485            "expected TransactionOversized, got {err:?}"
486        );
487    }
488
489    #[test]
490    fn tip_separate_tx_when_under_five() {
491        let payer = Keypair::new();
492        let inputs = make_bundle_inputs(TestBundleParams {
493            payer: &payer,
494            tx_count: 2,
495            blockhash: Hash::default(),
496            luts: &[],
497            jdf: None,
498            tip: 100_000,
499        });
500        let bundle = assert_build_ok(Bundle::new(inputs).build());
501        assert!(bundle.last_txn_is_tip);
502        assert_eq!(bundle.populated_count(), 3);
503        let tip_tx = get_slot(&bundle, 2);
504        assert_eq!(tip_tx.len(), 1);
505        assert_eq!(tip_tx[0].program_id, SYSTEM_PROGRAM_ID);
506    }
507
508    #[test]
509    fn tip_inline_when_five_txs() {
510        let payer = Keypair::new();
511        let inputs = make_bundle_inputs(TestBundleParams {
512            payer: &payer,
513            tx_count: 5,
514            blockhash: Hash::default(),
515            luts: &[],
516            jdf: None,
517            tip: 100_000,
518        });
519        let bundle = assert_build_ok(Bundle::new(inputs).build());
520        assert!(!bundle.last_txn_is_tip);
521        assert_eq!(bundle.populated_count(), 5);
522        let last_tx = get_slot(&bundle, 4);
523        let last_ix = &last_tx[last_tx.len() - 1];
524        assert_eq!(last_ix.program_id, SYSTEM_PROGRAM_ID);
525    }
526
527    #[test]
528    fn tip_account_is_valid_jito_account() {
529        let payer = Keypair::new();
530        let inputs = make_bundle_inputs(TestBundleParams {
531            payer: &payer,
532            tx_count: 1,
533            blockhash: Hash::default(),
534            luts: &[],
535            jdf: None,
536            tip: 100_000,
537        });
538        let bundle = assert_build_ok(Bundle::new(inputs).build());
539        assert!(
540            JITO_TIP_ACCOUNTS.contains(&bundle.tip_account),
541            "tip_account {} not in JITO_TIP_ACCOUNTS",
542            bundle.tip_account
543        );
544    }
545
546    #[test]
547    fn tip_lamports_encoded_correctly() {
548        let payer = Keypair::new();
549        let tip_amount: u64 = 500_000;
550        let inputs = make_bundle_inputs(TestBundleParams {
551            payer: &payer,
552            tx_count: 1,
553            blockhash: Hash::default(),
554            luts: &[],
555            jdf: None,
556            tip: tip_amount,
557        });
558        let bundle = assert_build_ok(Bundle::new(inputs).build());
559        let last_idx = bundle.last_populated_index();
560        assert!(last_idx.is_some(), "no populated slots found");
561        let tip_tx = get_slot(&bundle, last_idx.unwrap_or(0));
562        let tip_ix = if bundle.last_txn_is_tip {
563            &tip_tx[0]
564        } else {
565            &tip_tx[tip_tx.len() - 1]
566        };
567        let encoded_lamports = &tip_ix.data[4..12];
568        assert_eq!(encoded_lamports, &tip_amount.to_le_bytes());
569    }
570
571    #[test]
572    fn tip_account_in_lut_rejected() {
573        let payer = Keypair::new();
574        let lut_key = Pubkey::new_unique();
575        let lut = AddressLookupTableAccount {
576            key: lut_key,
577            addresses: JITO_TIP_ACCOUNTS.to_vec(),
578        };
579        let luts = [lut];
580        let inputs = make_bundle_inputs(TestBundleParams {
581            payer: &payer,
582            tx_count: 5,
583            blockhash: Hash::default(),
584            luts: &luts,
585            jdf: None,
586            tip: 100_000,
587        });
588        let result = Bundle::new(inputs).build();
589        assert!(result.is_err());
590        let err = result.err();
591        assert!(
592            matches!(err, Some(JitoError::TipAccountInLut { .. })),
593            "expected TipAccountInLut, got {err:?}"
594        );
595    }
596
597    #[test]
598    fn tip_appended_to_last_populated_slot_even_with_gaps() {
599        let payer = Keypair::new();
600        let pubkey = payer.pubkey();
601        let ix1 = make_custom_instruction(&pubkey, Pubkey::new_unique());
602        let ix2 = make_custom_instruction(&pubkey, Pubkey::new_unique());
603        let inputs = BundleBuilderInputs {
604            payer: &payer,
605            transactions_instructions: [Some(vec![ix1]), None, Some(vec![ix2]), None, None],
606            lookup_tables: &[],
607            recent_blockhash: Hash::default(),
608            tip_lamports: 100_000,
609            jitodontfront_pubkey: None,
610            compute_unit_limit: 200_000,
611        };
612        let bundle = assert_build_ok(Bundle::new(inputs).build());
613        let last_idx = match bundle.last_populated_index() {
614            Some(idx) => idx,
615            None => {
616                assert!(false, "no populated slots found");
617                std::process::abort();
618            }
619        };
620        let last_tx = get_slot(&bundle, last_idx);
621        let last_ix = &last_tx[last_tx.len() - 1];
622        assert_eq!(
623            last_ix.program_id, SYSTEM_PROGRAM_ID,
624            "expected tip to be appended to the last populated slot"
625        );
626    }
627
628    #[test]
629    fn jitodontfront_not_duplicated_if_already_present() {
630        let payer = Keypair::new();
631        let jdf = Pubkey::new_unique();
632        let mut ix = make_custom_instruction(&payer.pubkey(), Pubkey::new_unique());
633        ix.accounts.push(AccountMeta::new_readonly(jdf, false));
634        let inputs = BundleBuilderInputs {
635            payer: &payer,
636            transactions_instructions: [Some(vec![ix]), None, None, None, None],
637            lookup_tables: &[],
638            recent_blockhash: Hash::default(),
639            tip_lamports: 100_000,
640            jitodontfront_pubkey: Some(&jdf),
641            compute_unit_limit: 200_000,
642        };
643        let bundle = assert_build_ok(Bundle::new(inputs).build());
644        let first_ix = &get_slot(&bundle, 0)[0];
645        let count = first_ix
646            .accounts
647            .iter()
648            .filter(|acct| acct.pubkey == jdf)
649            .count();
650        assert_eq!(
651            count, 1,
652            "expected jitodontfront account to appear exactly once"
653        );
654    }
655}