1use std::sync::Arc;
2
3use jsonrpc_core::{BoxFuture, Error, Result};
4use jsonrpc_derive::rpc;
5use sha2::{Digest, Sha256};
6use solana_client::{rpc_config::RpcSendTransactionConfig, rpc_custom_error::RpcCustomError};
7use solana_rpc_client_api::response::{Response as RpcResponse, RpcResponseContext};
8use solana_signature::Signature;
9use solana_transaction::versioned::VersionedTransaction;
10use solana_transaction_status::{TransactionConfirmationStatus, UiTransactionEncoding};
11use surfpool_types::{JitoBundleStatus, TransactionStatusEvent};
12
13use super::{RunloopContext, utils::decode_and_deserialize};
14use crate::{
15 rpc::full::SurfpoolFullRpc,
16 surfnet::{locker::SurfnetSvmLocker, svm::BundleSandbox},
17};
18
19const MAX_BUNDLE_SIZE: usize = 5;
21
22const MAX_BUNDLES_PER_QUERY: usize = 5;
25
26#[rpc]
28pub trait Jito {
29 type Metadata;
30
31 #[rpc(meta, name = "sendBundle")]
76 fn send_bundle(
77 &self,
78 meta: Self::Metadata,
79 transactions: Vec<String>,
80 config: Option<RpcSendTransactionConfig>,
81 ) -> BoxFuture<Result<String>>;
82
83 #[rpc(meta, name = "getBundleStatuses")]
150 fn get_bundle_statuses(
151 &self,
152 meta: Self::Metadata,
153 bundle_ids: Vec<String>,
154 ) -> BoxFuture<Result<RpcResponse<Vec<Option<JitoBundleStatus>>>>>;
155}
156
157#[derive(Clone)]
158pub struct SurfpoolJitoRpc;
159
160impl Jito for SurfpoolJitoRpc {
161 type Metadata = Option<RunloopContext>;
162
163 fn send_bundle(
164 &self,
165 meta: Self::Metadata,
166 transactions: Vec<String>,
167 config: Option<RpcSendTransactionConfig>,
168 ) -> BoxFuture<Result<String>> {
169 Box::pin(async move {
170 if transactions.is_empty() {
171 return Err(Error::invalid_params("Bundle cannot be empty"));
172 }
173
174 if transactions.len() > MAX_BUNDLE_SIZE {
175 return Err(Error::invalid_params(format!(
176 "Bundle exceeds maximum size of {MAX_BUNDLE_SIZE} transactions"
177 )));
178 }
179
180 let Some(ctx) = meta else {
181 return Err(RpcCustomError::NodeUnhealthy {
182 num_slots_behind: None,
183 }
184 .into());
185 };
186
187 let base_config = config.unwrap_or_default();
188
189 let tx_encoding = base_config
192 .encoding
193 .unwrap_or(UiTransactionEncoding::Base58);
194 let binary_encoding = tx_encoding.into_binary_encoding().ok_or_else(|| {
195 Error::invalid_params(format!(
196 "unsupported encoding: {tx_encoding}. Supported encodings: base58, base64"
197 ))
198 })?;
199
200 let mut decoded_txs: Vec<VersionedTransaction> = Vec::with_capacity(transactions.len());
201 for (idx, tx_data) in transactions.iter().enumerate() {
202 let (_, tx) = decode_and_deserialize::<VersionedTransaction>(
203 tx_data.clone(),
204 binary_encoding,
205 )
206 .map_err(|e| Error {
207 code: e.code,
208 message: format!(
209 "Failed to decode bundle transaction {}: {}",
210 idx + 1,
211 e.message
212 ),
213 data: e.data,
214 })?;
215 decoded_txs.push(tx);
216 }
217
218 let bundle_sandbox = ctx
223 .svm_locker
224 .with_svm_reader(|svm_reader| svm_reader.clone_for_bundle_sandbox());
225
226 let BundleSandbox {
227 svm: sandbox_svm,
228 geyser_rx,
229 simnet_rx,
230 } = bundle_sandbox;
231
232 let sandbox_locker = SurfnetSvmLocker::new(sandbox_svm);
233
234 let remote_ctx = &None;
235 let skip_preflight = true;
236 let sigverify = true;
237
238 let mut bundle_signatures: Vec<Signature> = Vec::with_capacity(decoded_txs.len());
239 for (idx, tx) in decoded_txs.iter().enumerate() {
240 let (status_tx, status_rx) = crossbeam_channel::bounded(1);
241
242 let process_res = sandbox_locker
247 .process_transaction(
248 remote_ctx,
249 tx.clone(),
250 status_tx,
251 skip_preflight,
252 sigverify,
253 )
254 .await;
255
256 bundle_signatures.push(tx.signatures[0]);
257
258 if let Err(e) = process_res {
259 return Err(Error::invalid_params(format!(
262 "Jito bundle couldn't be executed, failed to process transaction {}: {e}",
263 idx + 1
264 )));
265 }
266
267 match status_rx.recv_timeout(std::time::Duration::from_secs(2)) {
272 Ok(TransactionStatusEvent::Success(_)) => {}
273 Ok(TransactionStatusEvent::SimulationFailure(other)) => {
274 return Err(Error::invalid_params(format!(
275 "Jito bundle couldn't be executed: simulation failed for transaction {}: {:?}",
276 idx + 1,
277 other
278 )));
279 }
280 Ok(TransactionStatusEvent::ExecutionFailure(other)) => {
281 return Err(Error::invalid_params(format!(
282 "Jito bundle couldn't be executed: Execution failed for transaction {}: {:?}",
283 idx + 1,
284 other
285 )));
286 }
287 Ok(TransactionStatusEvent::VerificationFailure(ver_fail_err)) => {
288 return Err(Error::invalid_params(format!(
289 "Jito bundle couldn't be executed: Verification failed for transaction {}: {:?}",
290 idx + 1,
291 ver_fail_err
292 )));
293 }
294 Err(_) => {
295 return Err(RpcCustomError::NodeUnhealthy {
296 num_slots_behind: None,
297 }
298 .into());
299 }
300 }
301 }
302
303 let sandbox_svm = match Arc::try_unwrap(sandbox_locker.0) {
308 Ok(rwlock) => rwlock.into_inner(),
309 Err(_) => {
310 return Err(Error::internal_error());
313 }
314 };
315 let reassembled = BundleSandbox {
316 svm: sandbox_svm,
317 geyser_rx,
318 simnet_rx,
319 };
320
321 let (bundle_status_tx, _bundle_status_rx) = crossbeam_channel::unbounded();
325
326 ctx.svm_locker
327 .with_svm_writer(move |original| {
328 original.commit_sandbox(reassembled, bundle_status_tx)
329 })
330 .map_err(|e| {
331 Error::invalid_params(format!(
332 "Jito bundle commit failed after successful sandbox execution: {e}"
333 ))
334 })?;
335
336 let concatenated_signatures = bundle_signatures
339 .iter()
340 .map(|sig| sig.to_string())
341 .collect::<Vec<_>>()
342 .join(",");
343 let mut hasher = Sha256::new();
344 hasher.update(concatenated_signatures.as_bytes());
345 let bundle_id = hex::encode(hasher.finalize());
346
347 ctx.svm_locker.store_bundle(
348 bundle_id.clone(),
349 bundle_signatures
350 .iter()
351 .map(|sig| sig.to_string())
352 .collect(),
353 )?;
354 Ok(bundle_id)
355 })
356 }
357
358 fn get_bundle_statuses(
359 &self,
360 meta: Self::Metadata,
361 bundle_ids: Vec<String>,
362 ) -> BoxFuture<Result<RpcResponse<Vec<Option<JitoBundleStatus>>>>> {
363 Box::pin(async move {
364 if bundle_ids.is_empty() {
365 return Err(Error::invalid_params("bundle_ids cannot be empty"));
366 }
367 if bundle_ids.len() > MAX_BUNDLES_PER_QUERY {
368 return Err(Error::invalid_params(format!(
369 "bundle_ids exceeds maximum of {MAX_BUNDLES_PER_QUERY} per request"
370 )));
371 }
372
373 let Some(ctx) = &meta else {
374 return Err(RpcCustomError::NodeUnhealthy {
375 num_slots_behind: None,
376 }
377 .into());
378 };
379
380 let mut last_context: Option<RpcResponseContext> = None;
385 let mut value: Vec<Option<JitoBundleStatus>> = Vec::with_capacity(bundle_ids.len());
386
387 for bundle_id in bundle_ids {
388 let Some(signatures) = ctx.svm_locker.get_bundle(&bundle_id) else {
389 value.push(None);
390 continue;
391 };
392 if signatures.is_empty() {
393 value.push(None);
394 continue;
395 }
396
397 let statuses = super::full::Full::get_signature_statuses(
398 &SurfpoolFullRpc,
399 meta.clone(),
400 signatures.clone(),
401 None,
402 )
403 .await?;
404
405 last_context = Some(statuses.context.clone());
406
407 let (slot, confirmation_status, first_err) = {
411 let mut iter = statuses.value.iter().flatten();
412
413 let (slot, confirmation_status, head_err) = match iter.next() {
414 Some(first) => (
415 first.slot,
416 first.confirmation_status.clone(),
417 first.err.clone(),
418 ),
419 None => (0, None, None),
420 };
421
422 let first_err = head_err.or_else(|| iter.find_map(|s| s.err.clone()));
423 (slot, confirmation_status, first_err)
424 };
425
426 let confirmation_status =
427 confirmation_status.unwrap_or(TransactionConfirmationStatus::Processed);
428
429 value.push(Some(JitoBundleStatus {
430 bundle_id,
431 transactions: signatures,
432 slot,
433 confirmation_status,
434 err: match first_err {
435 Some(e) => Err(e),
436 None => Ok(()),
437 },
438 }));
439 }
440
441 let context = last_context.unwrap_or_else(|| {
442 let slot = ctx
443 .svm_locker
444 .with_svm_reader(|svm| svm.get_latest_absolute_slot());
445 RpcResponseContext::new(slot)
446 });
447
448 Ok(RpcResponse { context, value })
449 })
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use std::{
456 sync::{
457 Arc,
458 atomic::{AtomicBool, AtomicUsize, Ordering},
459 },
460 time::Duration,
461 };
462
463 use sha2::{Digest, Sha256};
464 use solana_keypair::Keypair;
465 use solana_message::{VersionedMessage, v0::Message as V0Message};
466 use solana_pubkey::Pubkey;
467 use solana_signer::Signer;
468 use solana_system_interface::instruction as system_instruction;
469 use solana_transaction::versioned::VersionedTransaction;
470 use solana_transaction_status::TransactionConfirmationStatus as SolanaTxConfirmationStatus;
471 use surfpool_types::{SimnetCommand, TransactionConfirmationStatus, TransactionStatusEvent};
472
473 use super::*;
474 use crate::{
475 tests::helpers::TestSetup,
476 types::{SurfnetTransactionStatus, TransactionWithStatusMeta},
477 };
478
479 const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
480
481 fn build_v0_transaction(
482 payer: &Pubkey,
483 signers: &[&Keypair],
484 instructions: &[solana_instruction::Instruction],
485 recent_blockhash: &solana_hash::Hash,
486 ) -> VersionedTransaction {
487 let msg = VersionedMessage::V0(
488 V0Message::try_compile(payer, instructions, &[], *recent_blockhash).unwrap(),
489 );
490 VersionedTransaction::try_new(msg, signers).unwrap()
491 }
492
493 #[tokio::test(flavor = "multi_thread")]
494 async fn test_send_bundle_empty_bundle_rejected() {
495 let setup = TestSetup::new(SurfpoolJitoRpc);
496 let result = setup
497 .rpc
498 .send_bundle(Some(setup.context.clone()), vec![], None)
499 .await;
500
501 assert!(result.is_err());
502 let err = result.unwrap_err();
503 assert!(
504 err.message.contains("Bundle cannot be empty"),
505 "Expected 'Bundle cannot be empty' error, got: {}",
506 err.message
507 );
508 }
509
510 #[tokio::test(flavor = "multi_thread")]
511 async fn test_send_bundle_exceeds_max_size_rejected() {
512 let setup = TestSetup::new(SurfpoolJitoRpc);
513 let transactions = vec!["tx".to_string(); MAX_BUNDLE_SIZE + 1];
514 let result = setup
515 .rpc
516 .send_bundle(Some(setup.context.clone()), transactions, None)
517 .await;
518
519 assert!(result.is_err());
520 let err = result.unwrap_err();
521 assert!(
522 err.message.contains("exceeds maximum size"),
523 "Expected max size error, got: {}",
524 err.message
525 );
526 }
527
528 #[tokio::test(flavor = "multi_thread")]
529 async fn test_send_bundle_no_context_returns_unhealthy() {
530 let setup = TestSetup::new(SurfpoolJitoRpc);
531 let result = setup
532 .rpc
533 .send_bundle(None, vec!["some_tx".to_string()], None)
534 .await;
535
536 assert!(result.is_err());
537 }
538
539 #[tokio::test(flavor = "multi_thread")]
540 async fn test_get_bundle_statuses_unknown_bundle_returns_null_entry() {
541 let setup = TestSetup::new(SurfpoolJitoRpc);
542 let missing_id = "a".repeat(64);
543 let response = setup
544 .rpc
545 .get_bundle_statuses(Some(setup.context), vec![missing_id])
546 .await
547 .expect("getBundleStatuses should not return a JSON-RPC error");
548 assert_eq!(
549 response.value.len(),
550 1,
551 "value array must have one entry per requested bundle id"
552 );
553 assert!(
554 response.value[0].is_none(),
555 "unknown bundle_id should appear as a null entry inside `value`, not as an outer null"
556 );
557 }
558
559 #[tokio::test(flavor = "multi_thread")]
560 async fn test_get_bundle_statuses_empty_input_rejected() {
561 let setup = TestSetup::new(SurfpoolJitoRpc);
562 let result = setup
563 .rpc
564 .get_bundle_statuses(Some(setup.context), vec![])
565 .await;
566 assert!(result.is_err(), "empty bundle_ids should be rejected");
567 let err = result.unwrap_err();
568 assert!(
569 err.message.contains("cannot be empty"),
570 "Expected empty-input error, got: {}",
571 err.message
572 );
573 }
574
575 #[tokio::test(flavor = "multi_thread")]
576 async fn test_get_bundle_statuses_exceeds_max_per_query_rejected() {
577 let setup = TestSetup::new(SurfpoolJitoRpc);
578 let too_many = vec!["a".repeat(64); MAX_BUNDLES_PER_QUERY + 1];
579 let result = setup
580 .rpc
581 .get_bundle_statuses(Some(setup.context), too_many)
582 .await;
583 assert!(
584 result.is_err(),
585 "exceeding MAX_BUNDLES_PER_QUERY should error"
586 );
587 let err = result.unwrap_err();
588 assert!(
589 err.message.contains("exceeds maximum"),
590 "Expected max-batch error, got: {}",
591 err.message
592 );
593 }
594
595 #[tokio::test(flavor = "multi_thread")]
596 async fn test_get_bundle_statuses_no_context_returns_unhealthy() {
597 let setup = TestSetup::new(SurfpoolJitoRpc);
598 let result = setup
599 .rpc
600 .get_bundle_statuses(None, vec!["a".repeat(64)])
601 .await;
602 assert!(result.is_err());
603 }
604
605 #[tokio::test(flavor = "multi_thread")]
606 async fn test_send_bundle_single_transaction() {
607 let payer = Keypair::new();
608 let recipient = Pubkey::new_unique();
609 let setup = TestSetup::new(SurfpoolJitoRpc);
610 let recent_blockhash = setup
611 .context
612 .svm_locker
613 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
614
615 let _ = setup
617 .context
618 .svm_locker
619 .0
620 .write()
621 .await
622 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
623
624 let tx = build_v0_transaction(
625 &payer.pubkey(),
626 &[&payer],
627 &[system_instruction::transfer(
628 &payer.pubkey(),
629 &recipient,
630 LAMPORTS_PER_SOL,
631 )],
632 &recent_blockhash,
633 );
634 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
635 let expected_sig = tx.signatures[0];
636
637 let result = setup
638 .rpc
639 .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
640 .await;
641
642 assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
643
644 let bundle_id = result.unwrap();
646 let mut hasher = Sha256::new();
647 hasher.update(expected_sig.to_string().as_bytes());
648 let expected_bundle_id = hex::encode(hasher.finalize());
649 assert_eq!(
650 bundle_id, expected_bundle_id,
651 "Bundle ID should match SHA-256 of signature"
652 );
653
654 let recipient_lamports = setup
656 .context
657 .svm_locker
658 .with_svm_reader(|svm| svm.get_account(&recipient))
659 .ok()
660 .flatten()
661 .map(|a| a.lamports)
662 .unwrap_or(0);
663 assert_eq!(
664 recipient_lamports, LAMPORTS_PER_SOL,
665 "Bundle commit should have applied lamport transfer to recipient"
666 );
667 }
668
669 #[tokio::test(flavor = "multi_thread")]
670 async fn test_send_bundle_multiple_transactions() {
671 let payer = Keypair::new();
672 let recipient1 = Pubkey::new_unique();
673 let recipient2 = Pubkey::new_unique();
674 let setup = TestSetup::new(SurfpoolJitoRpc);
675 let recent_blockhash = setup
676 .context
677 .svm_locker
678 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
679
680 let _ = setup
682 .context
683 .svm_locker
684 .0
685 .write()
686 .await
687 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
688
689 let tx1 = build_v0_transaction(
690 &payer.pubkey(),
691 &[&payer],
692 &[system_instruction::transfer(
693 &payer.pubkey(),
694 &recipient1,
695 LAMPORTS_PER_SOL,
696 )],
697 &recent_blockhash,
698 );
699 let tx2 = build_v0_transaction(
700 &payer.pubkey(),
701 &[&payer],
702 &[system_instruction::transfer(
703 &payer.pubkey(),
704 &recipient2,
705 LAMPORTS_PER_SOL,
706 )],
707 &recent_blockhash,
708 );
709
710 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
711 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
712 let expected_sig1 = tx1.signatures[0];
713 let expected_sig2 = tx2.signatures[0];
714
715 let result = setup
716 .rpc
717 .send_bundle(
718 Some(setup.context.clone()),
719 vec![tx1_encoded, tx2_encoded],
720 None,
721 )
722 .await;
723
724 assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
725
726 let recipient1_lamports = setup
728 .context
729 .svm_locker
730 .with_svm_reader(|svm| svm.get_account(&recipient1))
731 .ok()
732 .flatten()
733 .map(|a| a.lamports)
734 .unwrap_or(0);
735 let recipient2_lamports = setup
736 .context
737 .svm_locker
738 .with_svm_reader(|svm| svm.get_account(&recipient2))
739 .ok()
740 .flatten()
741 .map(|a| a.lamports)
742 .unwrap_or(0);
743 assert_eq!(recipient1_lamports, LAMPORTS_PER_SOL);
744 assert_eq!(recipient2_lamports, LAMPORTS_PER_SOL);
745
746 let bundle_id = result.unwrap();
748 let concatenated = format!("{},{}", expected_sig1, expected_sig2);
749 let mut hasher = Sha256::new();
750 hasher.update(concatenated.as_bytes());
751 let expected_bundle_id = hex::encode(hasher.finalize());
752 assert_eq!(
753 bundle_id, expected_bundle_id,
754 "Bundle ID should match SHA-256 of comma-separated signatures"
755 );
756 }
757
758 #[tokio::test(flavor = "multi_thread")]
759 async fn test_send_bundle_dependent_transaction_failure_aborts_entire_bundle() {
760 let payer = Keypair::new();
761 let recipient = Keypair::new();
762
763 let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
766 let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
767
768 let observed_process_tx = Arc::new(AtomicUsize::new(0));
771 let stop_drain = Arc::new(AtomicBool::new(false));
772 let observed_process_tx_clone = observed_process_tx.clone();
773 let stop_drain_clone = stop_drain.clone();
774 let svm_locker_clone = setup.context.svm_locker.clone();
775 let drain_handle = hiro_system_kit::thread_named("mempool_drain_dependent_bundle")
776 .spawn(move || {
777 while !stop_drain_clone.load(Ordering::SeqCst) {
778 let Ok(cmd) = mempool_rx.recv_timeout(Duration::from_millis(200)) else {
779 continue;
780 };
781 match cmd {
782 SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _) => {
783 observed_process_tx_clone.fetch_add(1, Ordering::SeqCst);
784
785 let sig = tx.signatures[0];
787 let mut writer = svm_locker_clone.0.blocking_write();
788 let slot = writer.get_latest_absolute_slot();
789 writer.transactions_queued_for_confirmation.push_back((
790 tx.clone(),
791 status_tx.clone(),
792 None,
793 ));
794 let tx_with_status_meta = TransactionWithStatusMeta {
795 slot,
796 transaction: tx,
797 ..Default::default()
798 };
799 let mutated_accounts = std::collections::HashSet::new();
800 let _ = writer.transactions.store(
801 sig.to_string(),
802 SurfnetTransactionStatus::processed(
803 tx_with_status_meta,
804 mutated_accounts,
805 ),
806 );
807
808 let _ = status_tx.send(TransactionStatusEvent::Success(
809 TransactionConfirmationStatus::Confirmed,
810 ));
811 }
812 _ => continue,
813 }
814 }
815 })
816 .unwrap();
817
818 let recent_blockhash = setup
819 .context
820 .svm_locker
821 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
822
823 let _ = setup
825 .context
826 .svm_locker
827 .0
828 .write()
829 .await
830 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
831 let tx1 = build_v0_transaction(
833 &payer.pubkey(),
834 &[&payer],
835 &[system_instruction::transfer(
836 &payer.pubkey(),
837 &recipient.pubkey(),
838 LAMPORTS_PER_SOL,
839 )],
840 &recent_blockhash,
841 );
842
843 let tx2 = build_v0_transaction(
845 &recipient.pubkey(),
846 &[&recipient],
847 &[system_instruction::transfer(
848 &recipient.pubkey(),
849 &payer.pubkey(),
850 2 * LAMPORTS_PER_SOL,
851 )],
852 &recent_blockhash,
853 );
854
855 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
856 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
857
858 let result = setup
859 .rpc
860 .send_bundle(
861 Some(setup.context.clone()),
862 vec![tx1_encoded, tx2_encoded],
863 None,
864 )
865 .await;
866
867 assert!(
868 result.is_err(),
869 "Bundle should fail if any sandbox transaction fails"
870 );
871 let err = result.unwrap_err();
872 assert!(
873 err.message.contains("Jito bundle couldn't be executed"),
874 "Expected sandbox failure for tx2, got: {}",
875 err.message
876 );
877
878 stop_drain.store(true, Ordering::SeqCst);
879 let _ = drain_handle.join();
880
881 let recp_pubkey = recipient.pubkey();
882 let recp_bal = setup
883 .context
884 .svm_locker
885 .with_svm_reader(|svm| svm.get_account(&recp_pubkey))
886 .ok()
887 .flatten()
888 .map(|a| a.lamports)
889 .unwrap_or(0); assert_eq!(
892 recp_bal, 0,
893 "expected jito bundle to not take effect after bundle failure"
894 );
895
896 assert_eq!(
898 observed_process_tx.load(Ordering::SeqCst),
899 0,
900 "Expected zero mempool ProcessTransaction commands; sandbox failure should prevent Phase 2"
901 );
902 }
903
904 #[tokio::test(flavor = "multi_thread")]
905 async fn test_send_bundle_simulation_failure_returns_not_atomic_error() {
906 let setup = TestSetup::new(SurfpoolJitoRpc);
907
908 let payer = Keypair::new();
911 let recipient = Pubkey::new_unique();
912 let recent_blockhash = setup
913 .context
914 .svm_locker
915 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
916
917 let tx = build_v0_transaction(
918 &payer.pubkey(),
919 &[&payer],
920 &[system_instruction::transfer(
921 &payer.pubkey(),
922 &recipient,
923 LAMPORTS_PER_SOL,
924 )],
925 &recent_blockhash,
926 );
927 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
928
929 let result = setup
930 .rpc
931 .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
932 .await;
933 assert!(result.is_err());
934 let err = result.unwrap_err();
935
936 assert!(
937 err.message.contains("Jito bundle couldn't be executed"),
938 "Expected not-atomic error, got: {}",
939 err.message
940 );
941 assert!(
942 err.message.contains("Jito bundle couldn't be executed:"),
943 "Expected simulation-failure error for transaction 1, got: {}",
944 err.message
945 );
946 }
947
948 #[tokio::test(flavor = "multi_thread")]
949 async fn test_send_bundle_persists_bundle_signatures() {
950 let payer = Keypair::new();
951 let recipient = Pubkey::new_unique();
952 let (mempool_tx, _) = crossbeam_channel::unbounded();
953 let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
954
955 let recent_blockhash = setup
956 .context
957 .svm_locker
958 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
959
960 let _ = setup
962 .context
963 .svm_locker
964 .0
965 .write()
966 .await
967 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
968
969 let tx = build_v0_transaction(
970 &payer.pubkey(),
971 &[&payer],
972 &[system_instruction::transfer(
973 &payer.pubkey(),
974 &recipient,
975 LAMPORTS_PER_SOL,
976 )],
977 &recent_blockhash,
978 );
979 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
980
981 let expected_sigs = vec![tx.signatures[0].to_string()];
983
984 let setup_clone = setup.clone();
985 let send_bundle_result = setup_clone
986 .rpc
987 .send_bundle(Some(setup_clone.context), vec![tx_encoded], None)
988 .await;
989
990 assert!(send_bundle_result.is_ok(), "Expected send_bundle to pass");
991
992 let bundle_id = send_bundle_result.unwrap();
993
994 let started = std::time::Instant::now();
996 let timeout = std::time::Duration::from_secs(2);
997 let persisted = loop {
998 match setup.context.svm_locker.get_bundle(&bundle_id) {
999 Some(sigs) if !sigs.is_empty() => break sigs,
1000 _ if started.elapsed() > timeout => {
1001 panic!("timed out waiting for bundle to be persisted: {bundle_id}");
1002 }
1003 _ => std::thread::sleep(std::time::Duration::from_millis(10)),
1004 }
1005 };
1006 assert!(
1007 !persisted.is_empty(),
1008 "svm_locker.get_bundle(bundle_id) should not be empty"
1009 );
1010 assert_eq!(
1011 persisted, expected_sigs,
1012 "Persisted bundle signatures should match locally built signatures"
1013 );
1014
1015 let started = std::time::Instant::now();
1016 let timeout = std::time::Duration::from_secs(2);
1017 let (bundle, context_slot) = loop {
1018 let response = setup
1019 .rpc
1020 .get_bundle_statuses(Some(setup.context.clone()), vec![bundle_id.clone()])
1021 .await
1022 .expect("getBundleStatuses should succeed");
1023
1024 assert_eq!(
1025 response.value.len(),
1026 1,
1027 "getBundleStatuses should return a single status entry per requested id"
1028 );
1029
1030 let context_slot = response.context.slot;
1031 let bundle = response
1032 .value
1033 .into_iter()
1034 .next()
1035 .unwrap()
1036 .expect("bundle should exist locally after sendBundle");
1037 if bundle.slot != 0 {
1038 break (bundle, context_slot);
1039 }
1040
1041 if started.elapsed() > timeout {
1042 break (bundle, context_slot);
1043 }
1044
1045 std::thread::sleep(std::time::Duration::from_millis(10));
1046 };
1047
1048 assert!(
1049 context_slot >= bundle.slot,
1050 "response.context.slot ({}) should be >= bundle.slot ({}); \
1051 getBundleStatuses must surface the same context slot as the \
1052 underlying getSignatureStatuses call",
1053 context_slot,
1054 bundle.slot,
1055 );
1056
1057 assert_eq!(bundle.bundle_id, bundle_id, "bundle_id should match");
1058 assert_eq!(
1059 bundle.transactions, expected_sigs,
1060 "transactions should match bundle signatures"
1061 );
1062 assert!(
1063 matches!(
1064 bundle.confirmation_status,
1065 SolanaTxConfirmationStatus::Processed
1066 | SolanaTxConfirmationStatus::Confirmed
1067 | SolanaTxConfirmationStatus::Finalized
1068 ),
1069 "confirmation_status should be a valid Solana status"
1070 );
1071 assert!(bundle.err.is_ok(), "err should be Ok for successful bundle");
1072 }
1073
1074 #[tokio::test(flavor = "multi_thread")]
1075 async fn test_get_bundle_statuses_multi_transaction_bundle() {
1076 let payer = Keypair::new();
1077 let recipient1 = Pubkey::new_unique();
1078 let recipient2 = Pubkey::new_unique();
1079 let setup = TestSetup::new(SurfpoolJitoRpc);
1080 let recent_blockhash = setup
1081 .context
1082 .svm_locker
1083 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1084
1085 let _ = setup
1086 .context
1087 .svm_locker
1088 .0
1089 .write()
1090 .await
1091 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
1092
1093 let tx1 = build_v0_transaction(
1094 &payer.pubkey(),
1095 &[&payer],
1096 &[system_instruction::transfer(
1097 &payer.pubkey(),
1098 &recipient1,
1099 LAMPORTS_PER_SOL,
1100 )],
1101 &recent_blockhash,
1102 );
1103 let tx2 = build_v0_transaction(
1104 &payer.pubkey(),
1105 &[&payer],
1106 &[system_instruction::transfer(
1107 &payer.pubkey(),
1108 &recipient2,
1109 LAMPORTS_PER_SOL,
1110 )],
1111 &recent_blockhash,
1112 );
1113
1114 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
1115 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
1116 let expected_sigs = vec![tx1.signatures[0].to_string(), tx2.signatures[0].to_string()];
1117
1118 let bundle_id = setup
1119 .rpc
1120 .send_bundle(
1121 Some(setup.context.clone()),
1122 vec![tx1_encoded, tx2_encoded],
1123 None,
1124 )
1125 .await
1126 .expect("sendBundle should succeed for a valid 2-tx bundle");
1127
1128 let response = setup
1129 .rpc
1130 .get_bundle_statuses(Some(setup.context.clone()), vec![bundle_id.clone()])
1131 .await
1132 .expect("getBundleStatuses should succeed");
1133
1134 assert_eq!(
1137 response.value.len(),
1138 1,
1139 "value array must have one entry per requested bundle id"
1140 );
1141 let bundle = response
1142 .value
1143 .into_iter()
1144 .next()
1145 .unwrap()
1146 .expect("bundle should exist locally after sendBundle");
1147 assert_eq!(bundle.bundle_id, bundle_id);
1148 assert_eq!(
1149 bundle.transactions, expected_sigs,
1150 "transactions must preserve submission order across all txs in the bundle"
1151 );
1152 assert!(
1153 bundle.err.is_ok(),
1154 "successful multi-tx bundle should report Ok"
1155 );
1156 assert!(
1157 matches!(
1158 bundle.confirmation_status,
1159 SolanaTxConfirmationStatus::Processed
1160 | SolanaTxConfirmationStatus::Confirmed
1161 | SolanaTxConfirmationStatus::Finalized
1162 ),
1163 "confirmation_status should be a valid Solana status"
1164 );
1165 }
1166
1167 #[test]
1168 fn test_jito_bundle_status_json_shape() {
1169 use solana_transaction_error::TransactionError;
1170
1171 let ok_status = JitoBundleStatus {
1174 bundle_id: "abc123".to_string(),
1175 transactions: vec!["sig1".to_string(), "sig2".to_string()],
1176 slot: 42,
1177 confirmation_status: SolanaTxConfirmationStatus::Finalized,
1178 err: Ok(()),
1179 };
1180 let json = serde_json::to_value(&ok_status).expect("JitoBundleStatus should serialize");
1181
1182 assert!(
1183 json.get("bundle_id").is_some(),
1184 "expected snake_case `bundle_id` field, got: {json}"
1185 );
1186 assert!(json.get("transactions").is_some());
1187 assert!(json.get("slot").is_some());
1188 assert!(
1189 json.get("confirmationStatus").is_some(),
1190 "expected snake_case `confirmationStatus` field, got: {json}"
1191 );
1192 assert!(json.get("err").is_some());
1193
1194 assert!(
1195 json.get("bundleId").is_none(),
1196 "camelCase `bundleId` should not be serialized (Jito uses snake_case on the wire)"
1197 );
1198 assert!(
1199 json.get("confirmation_status").is_none(),
1200 "camelCase `confirmation_status` should not be serialized"
1201 );
1202
1203 assert_eq!(
1205 json.get("err"),
1206 Some(&serde_json::json!({ "Ok": null })),
1207 "Ok variant of err should serialize as {{\"Ok\": null}}"
1208 );
1209 assert_eq!(json.get("bundle_id").unwrap().as_str(), Some("abc123"));
1210 assert_eq!(json.get("slot").unwrap().as_u64(), Some(42));
1211 assert_eq!(
1212 json.get("confirmationStatus").unwrap().as_str(),
1213 Some("finalized"),
1214 "confirmationStatus should serialize as a lowercase string"
1215 );
1216
1217 let err_status = JitoBundleStatus {
1219 bundle_id: "abc123".to_string(),
1220 transactions: vec!["sig1".to_string()],
1221 slot: 7,
1222 confirmation_status: SolanaTxConfirmationStatus::Processed,
1223 err: Err(TransactionError::AccountNotFound),
1224 };
1225 let err_json = serde_json::to_value(&err_status).expect("err variant should serialize");
1226 let err_field = err_json.get("err").expect("err field should be present");
1227 assert!(
1228 err_field.get("Err").is_some(),
1229 "Err variant of err should serialize as {{\"Err\": ...}}, got: {err_field}"
1230 );
1231
1232 let round_tripped: JitoBundleStatus =
1234 serde_json::from_value(json).expect("JitoBundleStatus should round-trip");
1235 assert_eq!(round_tripped, ok_status);
1236 }
1237
1238 #[tokio::test(flavor = "multi_thread")]
1239 async fn test_get_bundle_statuses_batched_known_and_unknown() {
1240 let payer = Keypair::new();
1244 let recipient = Pubkey::new_unique();
1245 let setup = TestSetup::new(SurfpoolJitoRpc);
1246 let recent_blockhash = setup
1247 .context
1248 .svm_locker
1249 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1250
1251 let _ = setup
1252 .context
1253 .svm_locker
1254 .0
1255 .write()
1256 .await
1257 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
1258
1259 let tx = build_v0_transaction(
1260 &payer.pubkey(),
1261 &[&payer],
1262 &[system_instruction::transfer(
1263 &payer.pubkey(),
1264 &recipient,
1265 LAMPORTS_PER_SOL,
1266 )],
1267 &recent_blockhash,
1268 );
1269 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
1270
1271 let known_id = setup
1272 .rpc
1273 .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
1274 .await
1275 .expect("sendBundle should succeed");
1276 let unknown_id = "f".repeat(64);
1277
1278 let response = setup
1281 .rpc
1282 .get_bundle_statuses(
1283 Some(setup.context.clone()),
1284 vec![unknown_id.clone(), known_id.clone()],
1285 )
1286 .await
1287 .expect("getBundleStatuses should succeed");
1288
1289 assert_eq!(
1290 response.value.len(),
1291 2,
1292 "value must have exactly one entry per requested bundle id"
1293 );
1294 assert!(
1295 response.value[0].is_none(),
1296 "index 0 (unknown id) should be null"
1297 );
1298 let known = response.value[1]
1299 .as_ref()
1300 .expect("index 1 (known id) should be Some(JitoBundleStatus)");
1301 assert_eq!(known.bundle_id, known_id);
1302 }
1303}