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}