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}