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