1use std::sync::Arc;
2
3use jsonrpc_core::{BoxFuture, Error, Result};
4use jsonrpc_derive::rpc;
5use sha2::{Digest, Sha256};
6use solana_account_decoder::{UiAccount, UiAccountEncoding, encode_ui_account};
7use solana_client::{rpc_config::RpcSendTransactionConfig, rpc_custom_error::RpcCustomError};
8use solana_pubkey::Pubkey;
9use solana_rpc_client_api::response::{Response as RpcResponse, RpcBlockhash, RpcResponseContext};
10use solana_signature::Signature;
11use solana_transaction::versioned::VersionedTransaction;
12use solana_transaction_status::{TransactionConfirmationStatus, UiTransactionEncoding};
13use surfpool_types::{
14 JitoBundleStatus, RpcBundleExecutionError, RpcBundleRequest, RpcBundleSimulationSummary,
15 RpcSimulateBundleConfig, RpcSimulateBundleResult, RpcSimulateBundleTransactionResult,
16 TransactionStatusEvent,
17};
18
19use super::{RunloopContext, utils::decode_and_deserialize};
20use crate::{
21 rpc::full::SurfpoolFullRpc,
22 surfnet::{locker::SurfnetSvmLocker, svm::BundleSandbox},
23};
24
25const MAX_BUNDLE_SIZE: usize = 5;
27
28const MAX_BUNDLES_PER_QUERY: usize = 5;
31
32#[rpc]
34pub trait Jito {
35 type Metadata;
36
37 #[rpc(meta, name = "sendBundle")]
82 fn send_bundle(
83 &self,
84 meta: Self::Metadata,
85 transactions: Vec<String>,
86 config: Option<RpcSendTransactionConfig>,
87 ) -> BoxFuture<Result<String>>;
88
89 #[rpc(meta, name = "getBundleStatuses")]
156 fn get_bundle_statuses(
157 &self,
158 meta: Self::Metadata,
159 bundle_ids: Vec<String>,
160 ) -> BoxFuture<Result<RpcResponse<Vec<Option<JitoBundleStatus>>>>>;
161
162 #[rpc(meta, name = "simulateBundle")]
241 fn simulate_bundle(
242 &self,
243 meta: Self::Metadata,
244 rpc_bundle_request: RpcBundleRequest,
245 config: Option<RpcSimulateBundleConfig>,
246 ) -> BoxFuture<Result<RpcResponse<RpcSimulateBundleResult>>>;
247}
248
249#[derive(Clone)]
250pub struct SurfpoolJitoRpc;
251
252impl Jito for SurfpoolJitoRpc {
253 type Metadata = Option<RunloopContext>;
254
255 fn send_bundle(
256 &self,
257 meta: Self::Metadata,
258 transactions: Vec<String>,
259 config: Option<RpcSendTransactionConfig>,
260 ) -> BoxFuture<Result<String>> {
261 Box::pin(async move {
262 if transactions.is_empty() {
263 return Err(Error::invalid_params("Bundle cannot be empty"));
264 }
265
266 if transactions.len() > MAX_BUNDLE_SIZE {
267 return Err(Error::invalid_params(format!(
268 "Bundle exceeds maximum size of {MAX_BUNDLE_SIZE} transactions"
269 )));
270 }
271
272 let Some(ctx) = meta else {
273 return Err(RpcCustomError::NodeUnhealthy {
274 num_slots_behind: None,
275 }
276 .into());
277 };
278
279 let base_config = config.unwrap_or_default();
280
281 let tx_encoding = base_config
284 .encoding
285 .unwrap_or(UiTransactionEncoding::Base58);
286 let binary_encoding = tx_encoding.into_binary_encoding().ok_or_else(|| {
287 Error::invalid_params(format!(
288 "unsupported encoding: {tx_encoding}. Supported encodings: base58, base64"
289 ))
290 })?;
291
292 let mut decoded_txs: Vec<VersionedTransaction> = Vec::with_capacity(transactions.len());
293 for (idx, tx_data) in transactions.iter().enumerate() {
294 let (_, tx) = decode_and_deserialize::<VersionedTransaction>(
295 tx_data.clone(),
296 binary_encoding,
297 )
298 .map_err(|e| Error {
299 code: e.code,
300 message: format!(
301 "Failed to decode bundle transaction {}: {}",
302 idx + 1,
303 e.message
304 ),
305 data: e.data,
306 })?;
307 decoded_txs.push(tx);
308 }
309
310 let bundle_sandbox = ctx
315 .svm_locker
316 .with_svm_reader(|svm_reader| svm_reader.clone_for_bundle_sandbox());
317
318 let BundleSandbox {
319 svm: sandbox_svm,
320 geyser_rx,
321 simnet_rx,
322 } = bundle_sandbox;
323
324 let sandbox_locker = SurfnetSvmLocker::new(sandbox_svm);
325
326 let remote_ctx = &None;
327 let skip_preflight = true;
328 let sigverify = true;
329
330 let mut bundle_signatures: Vec<Signature> = Vec::with_capacity(decoded_txs.len());
331 for (idx, tx) in decoded_txs.iter().enumerate() {
332 let (status_tx, status_rx) = crossbeam_channel::bounded(1);
333
334 let process_res = sandbox_locker
339 .process_transaction(
340 remote_ctx,
341 tx.clone(),
342 status_tx,
343 skip_preflight,
344 sigverify,
345 )
346 .await;
347
348 bundle_signatures.push(tx.signatures[0]);
349
350 if let Err(e) = process_res {
351 return Err(Error::invalid_params(format!(
354 "Jito bundle couldn't be executed, failed to process transaction {}: {e}",
355 idx + 1
356 )));
357 }
358
359 match status_rx.recv_timeout(std::time::Duration::from_secs(2)) {
364 Ok(TransactionStatusEvent::Success(_)) => {}
365 Ok(TransactionStatusEvent::SimulationFailure(other)) => {
366 return Err(Error::invalid_params(format!(
367 "Jito bundle couldn't be executed: simulation failed for transaction {}: {:?}",
368 idx + 1,
369 other
370 )));
371 }
372 Ok(TransactionStatusEvent::ExecutionFailure(other)) => {
373 return Err(Error::invalid_params(format!(
374 "Jito bundle couldn't be executed: Execution failed for transaction {}: {:?}",
375 idx + 1,
376 other
377 )));
378 }
379 Ok(TransactionStatusEvent::VerificationFailure(ver_fail_err)) => {
380 return Err(Error::invalid_params(format!(
381 "Jito bundle couldn't be executed: Verification failed for transaction {}: {:?}",
382 idx + 1,
383 ver_fail_err
384 )));
385 }
386 Err(_) => {
387 return Err(RpcCustomError::NodeUnhealthy {
388 num_slots_behind: None,
389 }
390 .into());
391 }
392 }
393 }
394
395 let sandbox_svm = match Arc::try_unwrap(sandbox_locker.0) {
400 Ok(rwlock) => rwlock.into_inner(),
401 Err(_) => {
402 return Err(Error::internal_error());
405 }
406 };
407 let reassembled = BundleSandbox {
408 svm: sandbox_svm,
409 geyser_rx,
410 simnet_rx,
411 };
412
413 let (bundle_status_tx, _bundle_status_rx) = crossbeam_channel::unbounded();
417
418 ctx.svm_locker
419 .with_svm_writer(move |original| {
420 original.commit_sandbox(reassembled, bundle_status_tx)
421 })
422 .map_err(|e| {
423 Error::invalid_params(format!(
424 "Jito bundle commit failed after successful sandbox execution: {e}"
425 ))
426 })?;
427
428 let concatenated_signatures = bundle_signatures
431 .iter()
432 .map(|sig| sig.to_string())
433 .collect::<Vec<_>>()
434 .join(",");
435 let mut hasher = Sha256::new();
436 hasher.update(concatenated_signatures.as_bytes());
437 let bundle_id = hex::encode(hasher.finalize());
438
439 ctx.svm_locker.store_bundle(
440 bundle_id.clone(),
441 bundle_signatures
442 .iter()
443 .map(|sig| sig.to_string())
444 .collect(),
445 )?;
446 Ok(bundle_id)
447 })
448 }
449
450 fn get_bundle_statuses(
451 &self,
452 meta: Self::Metadata,
453 bundle_ids: Vec<String>,
454 ) -> BoxFuture<Result<RpcResponse<Vec<Option<JitoBundleStatus>>>>> {
455 Box::pin(async move {
456 if bundle_ids.is_empty() {
457 return Err(Error::invalid_params("bundle_ids cannot be empty"));
458 }
459 if bundle_ids.len() > MAX_BUNDLES_PER_QUERY {
460 return Err(Error::invalid_params(format!(
461 "bundle_ids exceeds maximum of {MAX_BUNDLES_PER_QUERY} per request"
462 )));
463 }
464
465 let Some(ctx) = &meta else {
466 return Err(RpcCustomError::NodeUnhealthy {
467 num_slots_behind: None,
468 }
469 .into());
470 };
471
472 let mut last_context: Option<RpcResponseContext> = None;
477 let mut value: Vec<Option<JitoBundleStatus>> = Vec::with_capacity(bundle_ids.len());
478
479 for bundle_id in bundle_ids {
480 let Some(signatures) = ctx.svm_locker.get_bundle(&bundle_id) else {
481 value.push(None);
482 continue;
483 };
484 if signatures.is_empty() {
485 value.push(None);
486 continue;
487 }
488
489 let statuses = super::full::Full::get_signature_statuses(
490 &SurfpoolFullRpc,
491 meta.clone(),
492 signatures.clone(),
493 None,
494 )
495 .await?;
496
497 last_context = Some(statuses.context.clone());
498
499 let (slot, confirmation_status, first_err) = {
503 let mut iter = statuses.value.iter().flatten();
504
505 let (slot, confirmation_status, head_err) = match iter.next() {
506 Some(first) => (
507 first.slot,
508 first.confirmation_status.clone(),
509 first.err.clone(),
510 ),
511 None => (0, None, None),
512 };
513
514 let first_err = head_err.or_else(|| iter.find_map(|s| s.err.clone()));
515 (slot, confirmation_status, first_err)
516 };
517
518 let confirmation_status =
519 confirmation_status.unwrap_or(TransactionConfirmationStatus::Processed);
520
521 value.push(Some(JitoBundleStatus {
522 bundle_id,
523 transactions: signatures,
524 slot,
525 confirmation_status,
526 err: match first_err {
527 Some(e) => Err(e),
528 None => Ok(()),
529 },
530 }));
531 }
532
533 let context = last_context.unwrap_or_else(|| {
534 let slot = ctx
535 .svm_locker
536 .with_svm_reader(|svm| svm.get_latest_absolute_slot());
537 RpcResponseContext::new(slot)
538 });
539
540 Ok(RpcResponse { context, value })
541 })
542 }
543
544 fn simulate_bundle(
545 &self,
546 meta: Self::Metadata,
547 rpc_bundle_request: RpcBundleRequest,
548 config: Option<RpcSimulateBundleConfig>,
549 ) -> BoxFuture<Result<RpcResponse<RpcSimulateBundleResult>>> {
550 Box::pin(async move {
551 if rpc_bundle_request.encoded_transactions.is_empty() {
554 return Err(Error::invalid_params("Bundle cannot be empty"));
555 }
556 if rpc_bundle_request.encoded_transactions.len() > MAX_BUNDLE_SIZE {
557 return Err(Error::invalid_params(format!(
558 "Bundle exceeds maximum size of {MAX_BUNDLE_SIZE} transactions"
559 )));
560 }
561
562 let Some(ctx) = meta else {
563 return Err(RpcCustomError::NodeUnhealthy {
564 num_slots_behind: None,
565 }
566 .into());
567 };
568
569 let bundle_len = rpc_bundle_request.encoded_transactions.len();
572 let config = config.unwrap_or_else(|| RpcSimulateBundleConfig {
573 pre_execution_accounts_configs: vec![None; bundle_len],
574 post_execution_accounts_configs: vec![None; bundle_len],
575 ..RpcSimulateBundleConfig::default()
576 });
577
578 let RpcSimulateBundleConfig {
579 pre_execution_accounts_configs,
580 post_execution_accounts_configs,
581 transaction_encoding,
582 simulation_bank: _simulation_bank, skip_sig_verify,
584 replace_recent_blockhash,
585 } = config;
586
587 let pre_execution_accounts_configs = if pre_execution_accounts_configs.is_empty() {
593 vec![None; bundle_len]
594 } else {
595 pre_execution_accounts_configs
596 };
597 let post_execution_accounts_configs = if post_execution_accounts_configs.is_empty() {
598 vec![None; bundle_len]
599 } else {
600 post_execution_accounts_configs
601 };
602
603 if pre_execution_accounts_configs.len() != bundle_len
607 || post_execution_accounts_configs.len() != bundle_len
608 {
609 return Err(Error::invalid_params(
610 "preExecutionAccountsConfigs/postExecutionAccountsConfigs, when provided, must be equal in length to the number of transactions",
611 ));
612 }
613
614 for cfg in pre_execution_accounts_configs
618 .iter()
619 .chain(post_execution_accounts_configs.iter())
620 {
621 if let Some(cfg) = cfg {
622 let enc = cfg.encoding.unwrap_or(UiAccountEncoding::Base64);
623 if enc != UiAccountEncoding::Base64 {
624 return Err(Error::invalid_params(
625 "Base64 is the only supported encoding for pre/post-execution accounts",
626 ));
627 }
628 }
629 }
630
631 if replace_recent_blockhash && !skip_sig_verify {
635 return Err(Error::invalid_params(
636 "replaceRecentBlockhash requires skipSigVerify=true (replacing the blockhash invalidates pre-existing signatures)",
637 ));
638 }
639
640 let tx_encoding = transaction_encoding.unwrap_or(UiTransactionEncoding::Base64);
645 if tx_encoding != UiTransactionEncoding::Base64 {
646 return Err(Error::invalid_params(
647 "Base64 is the only supported encoding for transactions in simulateBundle",
648 ));
649 }
650 let binary_encoding = tx_encoding
651 .into_binary_encoding()
652 .expect("Base64 has a binary encoding");
653
654 let mut decoded_txs: Vec<VersionedTransaction> = Vec::with_capacity(bundle_len);
657 for (idx, tx_data) in rpc_bundle_request.encoded_transactions.iter().enumerate() {
658 let (_, tx) = decode_and_deserialize::<VersionedTransaction>(
659 tx_data.clone(),
660 binary_encoding,
661 )
662 .map_err(|e| Error {
663 code: e.code,
664 message: format!(
665 "Failed to decode bundle transaction {}: {}",
666 idx + 1,
667 e.message
668 ),
669 data: e.data,
670 })?;
671 if tx.signatures.is_empty() {
681 return Err(Error::invalid_params(format!(
682 "Bundle transaction {} has no signatures",
683 idx + 1
684 )));
685 }
686 decoded_txs.push(tx);
687 }
688
689 let pre_pubkeys = parse_account_addresses(&pre_execution_accounts_configs)?;
692 let post_pubkeys = parse_account_addresses(&post_execution_accounts_configs)?;
693
694 let bundle_sandbox = ctx
705 .svm_locker
706 .with_svm_reader(|svm_reader| svm_reader.clone_for_bundle_sandbox());
707 let BundleSandbox {
708 svm: sandbox_svm,
709 geyser_rx: _geyser_rx, simnet_rx: _simnet_rx, } = bundle_sandbox;
712 let sandbox_locker = SurfnetSvmLocker::new(sandbox_svm);
713
714 let replacement_blockhash: Option<RpcBlockhash> = if replace_recent_blockhash {
719 let (latest_hash, last_valid_block_height) =
724 sandbox_locker.with_svm_reader(|svm| {
725 (svm.latest_blockhash(), svm.latest_epoch_info().block_height)
726 });
727 for tx in decoded_txs.iter_mut() {
728 tx.message.set_recent_blockhash(latest_hash);
729 }
730 Some(RpcBlockhash {
731 blockhash: latest_hash.to_string(),
732 last_valid_block_height,
733 })
734 } else {
735 None
736 };
737
738 let remote_ctx = &None;
739 let skip_preflight = true;
740 let sigverify = !skip_sig_verify;
741
742 let mut transaction_results: Vec<RpcSimulateBundleTransactionResult> = (0..bundle_len)
747 .map(|_| empty_tx_result(replacement_blockhash.clone()))
748 .collect();
749 let mut summary: RpcBundleSimulationSummary = RpcBundleSimulationSummary::Succeeded;
750
751 for (idx, tx) in decoded_txs.into_iter().enumerate() {
755 let signature: Signature = tx.signatures[0];
758 let pre_was_some = pre_execution_accounts_configs[idx].is_some();
759 let post_was_some = post_execution_accounts_configs[idx].is_some();
760
761 let pre_accounts = snapshot_accounts(&sandbox_locker, &pre_pubkeys[idx]).await?;
766
767 if sigverify {
774 if let Err(failed) =
775 sandbox_locker.with_svm_reader(|svm_reader| svm_reader.sigverify(&tx))
776 {
777 let post_accounts =
778 snapshot_accounts(&sandbox_locker, &post_pubkeys[idx]).await?;
779 let pre_for_idx = if pre_was_some {
780 Some(pre_accounts)
781 } else {
782 None
783 };
784 let post_for_idx = if post_was_some {
785 Some(post_accounts)
786 } else {
787 None
788 };
789 let typed = failed.err;
790 transaction_results[idx] = build_tx_result(
791 Some(typed.clone()),
792 None,
793 pre_for_idx,
794 post_for_idx,
795 None,
796 replacement_blockhash.clone(),
797 );
798 summary = RpcBundleSimulationSummary::Failed {
799 error: RpcBundleExecutionError::TransactionFailure(
800 signature,
801 typed.to_string(),
802 ),
803 tx_signature: Some(signature.to_string()),
804 };
805 break;
806 }
807 }
808
809 let (status_tx, status_rx) = crossbeam_channel::unbounded();
818 let profile_res = sandbox_locker
819 .fetch_all_tx_accounts_then_process_tx_returning_profile_res(
820 remote_ctx,
821 tx,
822 &status_tx,
823 skip_preflight,
824 false,
827 true, )
829 .await;
830
831 let post_accounts = snapshot_accounts(&sandbox_locker, &post_pubkeys[idx]).await?;
835
836 let pre_for_idx = if pre_was_some {
837 Some(pre_accounts)
838 } else {
839 None
840 };
841 let post_for_idx = if post_was_some {
842 Some(post_accounts)
843 } else {
844 None
845 };
846
847 match profile_res {
848 Ok(keyed) => {
849 let profile = &keyed.transaction_profile;
850 let typed_err: Option<solana_transaction_error::TransactionError> =
855 match status_rx.try_recv() {
856 Ok(TransactionStatusEvent::SimulationFailure((err, _meta))) => {
857 Some(err)
858 }
859 Ok(TransactionStatusEvent::ExecutionFailure((err, _meta))) => {
860 Some(err)
861 }
862 Ok(TransactionStatusEvent::VerificationFailure(_)) => Some(
870 solana_transaction_error::TransactionError::SignatureFailure,
871 ),
872 Ok(TransactionStatusEvent::Success(_)) | Err(_) => None,
873 };
874
875 if let Some(err_msg) = profile.error_message.clone() {
876 transaction_results[idx] = build_tx_result(
883 typed_err,
884 profile.log_messages.clone(),
885 pre_for_idx,
886 post_for_idx,
887 Some(profile.compute_units_consumed),
888 replacement_blockhash.clone(),
889 );
890 summary = RpcBundleSimulationSummary::Failed {
891 error: RpcBundleExecutionError::TransactionFailure(
892 signature, err_msg,
893 ),
894 tx_signature: Some(signature.to_string()),
895 };
896 break;
897 }
898
899 transaction_results[idx] = build_tx_result(
901 None,
902 profile.log_messages.clone(),
903 pre_for_idx,
904 post_for_idx,
905 Some(profile.compute_units_consumed),
906 replacement_blockhash.clone(),
907 );
908 }
909 Err(e) => {
910 let typed_err: Option<solana_transaction_error::TransactionError> =
918 match status_rx.try_recv() {
919 Ok(TransactionStatusEvent::SimulationFailure((err, _meta))) => {
920 Some(err)
921 }
922 Ok(TransactionStatusEvent::ExecutionFailure((err, _meta))) => {
923 Some(err)
924 }
925 Ok(TransactionStatusEvent::VerificationFailure(_)) => Some(
926 solana_transaction_error::TransactionError::SignatureFailure,
927 ),
928 Ok(_) | Err(_) => None,
929 };
930 transaction_results[idx] = build_tx_result(
931 typed_err,
932 None,
933 pre_for_idx,
934 post_for_idx,
935 None,
936 replacement_blockhash.clone(),
937 );
938 summary = RpcBundleSimulationSummary::Failed {
939 error: RpcBundleExecutionError::TransactionFailure(
940 signature,
941 e.to_string(),
942 ),
943 tx_signature: Some(signature.to_string()),
944 };
945 break;
946 }
947 }
948 }
949
950 drop(sandbox_locker);
954
955 let slot = ctx
956 .svm_locker
957 .with_svm_reader(|svm| svm.get_latest_absolute_slot());
958
959 Ok(RpcResponse {
960 context: RpcResponseContext::new(slot),
961 value: RpcSimulateBundleResult {
962 summary,
963 transaction_results,
964 },
965 })
966 })
967 }
968}
969
970fn parse_account_addresses(
983 configs: &[Option<surfpool_types::RpcSimulateTransactionAccountsConfig>],
984) -> Result<Vec<Vec<Pubkey>>> {
985 configs
986 .iter()
987 .map(|cfg| {
988 let addresses = match cfg {
989 Some(c) => &c.addresses,
990 None => return Ok(Vec::new()),
991 };
992 addresses
993 .iter()
994 .map(|s| {
995 Pubkey::try_from(s.as_str()).map_err(|_| {
996 Error::invalid_params(format!("Invalid pubkey in pre/post accounts: {s}"))
997 })
998 })
999 .collect()
1000 })
1001 .collect()
1002}
1003
1004async fn snapshot_accounts(
1010 sandbox_locker: &SurfnetSvmLocker,
1011 pubkeys: &[Pubkey],
1012) -> Result<Vec<UiAccount>> {
1013 if pubkeys.is_empty() {
1014 return Ok(Vec::new());
1015 }
1016
1017 let remote_ctx = &None;
1018 let contextualized = sandbox_locker
1019 .get_multiple_accounts(remote_ctx, pubkeys, None)
1020 .await
1021 .map_err(|e| Error {
1022 code: jsonrpc_core::ErrorCode::InternalError,
1023 message: format!("Failed to fetch pre/post-execution accounts: {e}"),
1024 data: None,
1025 })?;
1026
1027 let mut out = Vec::with_capacity(pubkeys.len());
1028 for (idx, result) in contextualized.inner.iter().enumerate() {
1029 let pubkey = pubkeys[idx];
1030 let account = match result {
1031 crate::surfnet::GetAccountResult::None(_) => {
1032 solana_account::Account {
1042 lamports: 0,
1043 data: Vec::new(),
1044 owner: solana_system_interface::program::id(),
1045 executable: false,
1046 rent_epoch: 0,
1047 }
1048 }
1049 crate::surfnet::GetAccountResult::FoundAccount(_, account, _)
1050 | crate::surfnet::GetAccountResult::FoundProgramAccount((_, account), _)
1051 | crate::surfnet::GetAccountResult::FoundTokenAccount((_, account), _) => {
1052 account.clone()
1053 }
1054 };
1055 out.push(encode_ui_account(
1056 &pubkey,
1057 &account,
1058 UiAccountEncoding::Base64,
1059 None,
1060 None,
1061 ));
1062 }
1063 Ok(out)
1064}
1065
1066fn empty_tx_result(
1070 replacement_blockhash: Option<RpcBlockhash>,
1071) -> RpcSimulateBundleTransactionResult {
1072 RpcSimulateBundleTransactionResult {
1073 err: None,
1074 logs: None,
1075 pre_execution_accounts: None,
1076 post_execution_accounts: None,
1077 units_consumed: None,
1078 loaded_accounts_data_size: None,
1079 return_data: None,
1080 replacement_blockhash,
1081 fee: None,
1082 pre_balances: None,
1083 post_balances: None,
1084 pre_token_balances: None,
1085 post_token_balances: None,
1086 loaded_addresses: None,
1087 }
1088}
1089
1090fn build_tx_result(
1100 err: Option<solana_transaction_error::TransactionError>,
1101 logs: Option<Vec<String>>,
1102 pre_execution_accounts: Option<Vec<UiAccount>>,
1103 post_execution_accounts: Option<Vec<UiAccount>>,
1104 units_consumed: Option<u64>,
1105 replacement_blockhash: Option<RpcBlockhash>,
1106) -> RpcSimulateBundleTransactionResult {
1107 RpcSimulateBundleTransactionResult {
1108 err,
1109 logs,
1110 pre_execution_accounts,
1111 post_execution_accounts,
1112 units_consumed,
1113 loaded_accounts_data_size: None,
1114 return_data: None,
1115 replacement_blockhash,
1116 fee: None,
1117 pre_balances: None,
1118 post_balances: None,
1119 pre_token_balances: None,
1120 post_token_balances: None,
1121 loaded_addresses: None,
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use std::{
1128 sync::{
1129 Arc,
1130 atomic::{AtomicBool, AtomicUsize, Ordering},
1131 },
1132 time::Duration,
1133 };
1134
1135 use sha2::{Digest, Sha256};
1136 use solana_keypair::Keypair;
1137 use solana_message::{VersionedMessage, v0::Message as V0Message};
1138 use solana_pubkey::Pubkey;
1139 use solana_signer::Signer;
1140 use solana_system_interface::instruction as system_instruction;
1141 use solana_transaction::versioned::VersionedTransaction;
1142 use solana_transaction_status::TransactionConfirmationStatus as SolanaTxConfirmationStatus;
1143 use surfpool_types::{SimnetCommand, TransactionConfirmationStatus, TransactionStatusEvent};
1144
1145 use super::*;
1146 use crate::{
1147 tests::helpers::TestSetup,
1148 types::{SurfnetTransactionStatus, TransactionWithStatusMeta},
1149 };
1150
1151 const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
1152
1153 fn build_v0_transaction(
1154 payer: &Pubkey,
1155 signers: &[&Keypair],
1156 instructions: &[solana_instruction::Instruction],
1157 recent_blockhash: &solana_hash::Hash,
1158 ) -> VersionedTransaction {
1159 let msg = VersionedMessage::V0(
1160 V0Message::try_compile(payer, instructions, &[], *recent_blockhash).unwrap(),
1161 );
1162 VersionedTransaction::try_new(msg, signers).unwrap()
1163 }
1164
1165 #[tokio::test(flavor = "multi_thread")]
1166 async fn test_send_bundle_empty_bundle_rejected() {
1167 let setup = TestSetup::new(SurfpoolJitoRpc);
1168 let result = setup
1169 .rpc
1170 .send_bundle(Some(setup.context.clone()), vec![], None)
1171 .await;
1172
1173 assert!(result.is_err());
1174 let err = result.unwrap_err();
1175 assert!(
1176 err.message.contains("Bundle cannot be empty"),
1177 "Expected 'Bundle cannot be empty' error, got: {}",
1178 err.message
1179 );
1180 }
1181
1182 #[tokio::test(flavor = "multi_thread")]
1183 async fn test_send_bundle_exceeds_max_size_rejected() {
1184 let setup = TestSetup::new(SurfpoolJitoRpc);
1185 let transactions = vec!["tx".to_string(); MAX_BUNDLE_SIZE + 1];
1186 let result = setup
1187 .rpc
1188 .send_bundle(Some(setup.context.clone()), transactions, None)
1189 .await;
1190
1191 assert!(result.is_err());
1192 let err = result.unwrap_err();
1193 assert!(
1194 err.message.contains("exceeds maximum size"),
1195 "Expected max size error, got: {}",
1196 err.message
1197 );
1198 }
1199
1200 #[tokio::test(flavor = "multi_thread")]
1201 async fn test_send_bundle_no_context_returns_unhealthy() {
1202 let setup = TestSetup::new(SurfpoolJitoRpc);
1203 let result = setup
1204 .rpc
1205 .send_bundle(None, vec!["some_tx".to_string()], None)
1206 .await;
1207
1208 assert!(result.is_err());
1209 }
1210
1211 #[tokio::test(flavor = "multi_thread")]
1212 async fn test_get_bundle_statuses_unknown_bundle_returns_null_entry() {
1213 let setup = TestSetup::new(SurfpoolJitoRpc);
1214 let missing_id = "a".repeat(64);
1215 let response = setup
1216 .rpc
1217 .get_bundle_statuses(Some(setup.context), vec![missing_id])
1218 .await
1219 .expect("getBundleStatuses should not return a JSON-RPC error");
1220 assert_eq!(
1221 response.value.len(),
1222 1,
1223 "value array must have one entry per requested bundle id"
1224 );
1225 assert!(
1226 response.value[0].is_none(),
1227 "unknown bundle_id should appear as a null entry inside `value`, not as an outer null"
1228 );
1229 }
1230
1231 #[tokio::test(flavor = "multi_thread")]
1232 async fn test_get_bundle_statuses_empty_input_rejected() {
1233 let setup = TestSetup::new(SurfpoolJitoRpc);
1234 let result = setup
1235 .rpc
1236 .get_bundle_statuses(Some(setup.context), vec![])
1237 .await;
1238 assert!(result.is_err(), "empty bundle_ids should be rejected");
1239 let err = result.unwrap_err();
1240 assert!(
1241 err.message.contains("cannot be empty"),
1242 "Expected empty-input error, got: {}",
1243 err.message
1244 );
1245 }
1246
1247 #[tokio::test(flavor = "multi_thread")]
1248 async fn test_get_bundle_statuses_exceeds_max_per_query_rejected() {
1249 let setup = TestSetup::new(SurfpoolJitoRpc);
1250 let too_many = vec!["a".repeat(64); MAX_BUNDLES_PER_QUERY + 1];
1251 let result = setup
1252 .rpc
1253 .get_bundle_statuses(Some(setup.context), too_many)
1254 .await;
1255 assert!(
1256 result.is_err(),
1257 "exceeding MAX_BUNDLES_PER_QUERY should error"
1258 );
1259 let err = result.unwrap_err();
1260 assert!(
1261 err.message.contains("exceeds maximum"),
1262 "Expected max-batch error, got: {}",
1263 err.message
1264 );
1265 }
1266
1267 #[tokio::test(flavor = "multi_thread")]
1268 async fn test_get_bundle_statuses_no_context_returns_unhealthy() {
1269 let setup = TestSetup::new(SurfpoolJitoRpc);
1270 let result = setup
1271 .rpc
1272 .get_bundle_statuses(None, vec!["a".repeat(64)])
1273 .await;
1274 assert!(result.is_err());
1275 }
1276
1277 #[tokio::test(flavor = "multi_thread")]
1278 async fn test_send_bundle_single_transaction() {
1279 let payer = Keypair::new();
1280 let recipient = Pubkey::new_unique();
1281 let setup = TestSetup::new(SurfpoolJitoRpc);
1282 let recent_blockhash = setup
1283 .context
1284 .svm_locker
1285 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1286
1287 let _ = setup
1289 .context
1290 .svm_locker
1291 .0
1292 .write()
1293 .await
1294 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
1295
1296 let tx = build_v0_transaction(
1297 &payer.pubkey(),
1298 &[&payer],
1299 &[system_instruction::transfer(
1300 &payer.pubkey(),
1301 &recipient,
1302 LAMPORTS_PER_SOL,
1303 )],
1304 &recent_blockhash,
1305 );
1306 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
1307 let expected_sig = tx.signatures[0];
1308
1309 let result = setup
1310 .rpc
1311 .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
1312 .await;
1313
1314 assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
1315
1316 let bundle_id = result.unwrap();
1318 let mut hasher = Sha256::new();
1319 hasher.update(expected_sig.to_string().as_bytes());
1320 let expected_bundle_id = hex::encode(hasher.finalize());
1321 assert_eq!(
1322 bundle_id, expected_bundle_id,
1323 "Bundle ID should match SHA-256 of signature"
1324 );
1325
1326 let recipient_lamports = setup
1328 .context
1329 .svm_locker
1330 .with_svm_reader(|svm| svm.get_account(&recipient))
1331 .ok()
1332 .flatten()
1333 .map(|a| a.lamports)
1334 .unwrap_or(0);
1335 assert_eq!(
1336 recipient_lamports, LAMPORTS_PER_SOL,
1337 "Bundle commit should have applied lamport transfer to recipient"
1338 );
1339 }
1340
1341 #[tokio::test(flavor = "multi_thread")]
1342 async fn test_send_bundle_multiple_transactions() {
1343 let payer = Keypair::new();
1344 let recipient1 = Pubkey::new_unique();
1345 let recipient2 = Pubkey::new_unique();
1346 let setup = TestSetup::new(SurfpoolJitoRpc);
1347 let recent_blockhash = setup
1348 .context
1349 .svm_locker
1350 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1351
1352 let _ = setup
1354 .context
1355 .svm_locker
1356 .0
1357 .write()
1358 .await
1359 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
1360
1361 let tx1 = build_v0_transaction(
1362 &payer.pubkey(),
1363 &[&payer],
1364 &[system_instruction::transfer(
1365 &payer.pubkey(),
1366 &recipient1,
1367 LAMPORTS_PER_SOL,
1368 )],
1369 &recent_blockhash,
1370 );
1371 let tx2 = build_v0_transaction(
1372 &payer.pubkey(),
1373 &[&payer],
1374 &[system_instruction::transfer(
1375 &payer.pubkey(),
1376 &recipient2,
1377 LAMPORTS_PER_SOL,
1378 )],
1379 &recent_blockhash,
1380 );
1381
1382 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
1383 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
1384 let expected_sig1 = tx1.signatures[0];
1385 let expected_sig2 = tx2.signatures[0];
1386
1387 let result = setup
1388 .rpc
1389 .send_bundle(
1390 Some(setup.context.clone()),
1391 vec![tx1_encoded, tx2_encoded],
1392 None,
1393 )
1394 .await;
1395
1396 assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
1397
1398 let recipient1_lamports = setup
1400 .context
1401 .svm_locker
1402 .with_svm_reader(|svm| svm.get_account(&recipient1))
1403 .ok()
1404 .flatten()
1405 .map(|a| a.lamports)
1406 .unwrap_or(0);
1407 let recipient2_lamports = setup
1408 .context
1409 .svm_locker
1410 .with_svm_reader(|svm| svm.get_account(&recipient2))
1411 .ok()
1412 .flatten()
1413 .map(|a| a.lamports)
1414 .unwrap_or(0);
1415 assert_eq!(recipient1_lamports, LAMPORTS_PER_SOL);
1416 assert_eq!(recipient2_lamports, LAMPORTS_PER_SOL);
1417
1418 let bundle_id = result.unwrap();
1420 let concatenated = format!("{},{}", expected_sig1, expected_sig2);
1421 let mut hasher = Sha256::new();
1422 hasher.update(concatenated.as_bytes());
1423 let expected_bundle_id = hex::encode(hasher.finalize());
1424 assert_eq!(
1425 bundle_id, expected_bundle_id,
1426 "Bundle ID should match SHA-256 of comma-separated signatures"
1427 );
1428 }
1429
1430 #[tokio::test(flavor = "multi_thread")]
1431 async fn test_send_bundle_dependent_transaction_failure_aborts_entire_bundle() {
1432 let payer = Keypair::new();
1433 let recipient = Keypair::new();
1434
1435 let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
1438 let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
1439
1440 let observed_process_tx = Arc::new(AtomicUsize::new(0));
1443 let stop_drain = Arc::new(AtomicBool::new(false));
1444 let observed_process_tx_clone = observed_process_tx.clone();
1445 let stop_drain_clone = stop_drain.clone();
1446 let svm_locker_clone = setup.context.svm_locker.clone();
1447 let drain_handle = hiro_system_kit::thread_named("mempool_drain_dependent_bundle")
1448 .spawn(move || {
1449 while !stop_drain_clone.load(Ordering::SeqCst) {
1450 let Ok(cmd) = mempool_rx.recv_timeout(Duration::from_millis(200)) else {
1451 continue;
1452 };
1453 match cmd {
1454 SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _) => {
1455 observed_process_tx_clone.fetch_add(1, Ordering::SeqCst);
1456
1457 let sig = tx.signatures[0];
1459 let mut writer = svm_locker_clone.0.blocking_write();
1460 let slot = writer.get_latest_absolute_slot();
1461 writer.transactions_queued_for_confirmation.push_back((
1462 tx.clone(),
1463 status_tx.clone(),
1464 None,
1465 ));
1466 let tx_with_status_meta = TransactionWithStatusMeta {
1467 slot,
1468 transaction: tx,
1469 ..Default::default()
1470 };
1471 let mutated_accounts = std::collections::HashSet::new();
1472 let _ = writer.transactions.store(
1473 sig.to_string(),
1474 SurfnetTransactionStatus::processed(
1475 tx_with_status_meta,
1476 mutated_accounts,
1477 ),
1478 );
1479
1480 let _ = status_tx.send(TransactionStatusEvent::Success(
1481 TransactionConfirmationStatus::Confirmed,
1482 ));
1483 }
1484 _ => continue,
1485 }
1486 }
1487 })
1488 .unwrap();
1489
1490 let recent_blockhash = setup
1491 .context
1492 .svm_locker
1493 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1494
1495 let _ = setup
1497 .context
1498 .svm_locker
1499 .0
1500 .write()
1501 .await
1502 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
1503 let tx1 = build_v0_transaction(
1505 &payer.pubkey(),
1506 &[&payer],
1507 &[system_instruction::transfer(
1508 &payer.pubkey(),
1509 &recipient.pubkey(),
1510 LAMPORTS_PER_SOL,
1511 )],
1512 &recent_blockhash,
1513 );
1514
1515 let tx2 = build_v0_transaction(
1517 &recipient.pubkey(),
1518 &[&recipient],
1519 &[system_instruction::transfer(
1520 &recipient.pubkey(),
1521 &payer.pubkey(),
1522 2 * LAMPORTS_PER_SOL,
1523 )],
1524 &recent_blockhash,
1525 );
1526
1527 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
1528 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
1529
1530 let result = setup
1531 .rpc
1532 .send_bundle(
1533 Some(setup.context.clone()),
1534 vec![tx1_encoded, tx2_encoded],
1535 None,
1536 )
1537 .await;
1538
1539 assert!(
1540 result.is_err(),
1541 "Bundle should fail if any sandbox transaction fails"
1542 );
1543 let err = result.unwrap_err();
1544 assert!(
1545 err.message.contains("Jito bundle couldn't be executed"),
1546 "Expected sandbox failure for tx2, got: {}",
1547 err.message
1548 );
1549
1550 stop_drain.store(true, Ordering::SeqCst);
1551 let _ = drain_handle.join();
1552
1553 let recp_pubkey = recipient.pubkey();
1554 let recp_bal = setup
1555 .context
1556 .svm_locker
1557 .with_svm_reader(|svm| svm.get_account(&recp_pubkey))
1558 .ok()
1559 .flatten()
1560 .map(|a| a.lamports)
1561 .unwrap_or(0); assert_eq!(
1564 recp_bal, 0,
1565 "expected jito bundle to not take effect after bundle failure"
1566 );
1567
1568 assert_eq!(
1570 observed_process_tx.load(Ordering::SeqCst),
1571 0,
1572 "Expected zero mempool ProcessTransaction commands; sandbox failure should prevent Phase 2"
1573 );
1574 }
1575
1576 #[tokio::test(flavor = "multi_thread")]
1577 async fn test_send_bundle_simulation_failure_returns_not_atomic_error() {
1578 let setup = TestSetup::new(SurfpoolJitoRpc);
1579
1580 let payer = Keypair::new();
1583 let recipient = Pubkey::new_unique();
1584 let recent_blockhash = setup
1585 .context
1586 .svm_locker
1587 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1588
1589 let tx = build_v0_transaction(
1590 &payer.pubkey(),
1591 &[&payer],
1592 &[system_instruction::transfer(
1593 &payer.pubkey(),
1594 &recipient,
1595 LAMPORTS_PER_SOL,
1596 )],
1597 &recent_blockhash,
1598 );
1599 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
1600
1601 let result = setup
1602 .rpc
1603 .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
1604 .await;
1605 assert!(result.is_err());
1606 let err = result.unwrap_err();
1607
1608 assert!(
1609 err.message.contains("Jito bundle couldn't be executed"),
1610 "Expected not-atomic error, got: {}",
1611 err.message
1612 );
1613 assert!(
1614 err.message.contains("Jito bundle couldn't be executed:"),
1615 "Expected simulation-failure error for transaction 1, got: {}",
1616 err.message
1617 );
1618 }
1619
1620 #[tokio::test(flavor = "multi_thread")]
1621 async fn test_send_bundle_persists_bundle_signatures() {
1622 let payer = Keypair::new();
1623 let recipient = Pubkey::new_unique();
1624 let (mempool_tx, _) = crossbeam_channel::unbounded();
1625 let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
1626
1627 let recent_blockhash = setup
1628 .context
1629 .svm_locker
1630 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1631
1632 let _ = setup
1634 .context
1635 .svm_locker
1636 .0
1637 .write()
1638 .await
1639 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
1640
1641 let tx = build_v0_transaction(
1642 &payer.pubkey(),
1643 &[&payer],
1644 &[system_instruction::transfer(
1645 &payer.pubkey(),
1646 &recipient,
1647 LAMPORTS_PER_SOL,
1648 )],
1649 &recent_blockhash,
1650 );
1651 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
1652
1653 let expected_sigs = vec![tx.signatures[0].to_string()];
1655
1656 let setup_clone = setup.clone();
1657 let send_bundle_result = setup_clone
1658 .rpc
1659 .send_bundle(Some(setup_clone.context), vec![tx_encoded], None)
1660 .await;
1661
1662 assert!(send_bundle_result.is_ok(), "Expected send_bundle to pass");
1663
1664 let bundle_id = send_bundle_result.unwrap();
1665
1666 let started = std::time::Instant::now();
1668 let timeout = std::time::Duration::from_secs(2);
1669 let persisted = loop {
1670 match setup.context.svm_locker.get_bundle(&bundle_id) {
1671 Some(sigs) if !sigs.is_empty() => break sigs,
1672 _ if started.elapsed() > timeout => {
1673 panic!("timed out waiting for bundle to be persisted: {bundle_id}");
1674 }
1675 _ => std::thread::sleep(std::time::Duration::from_millis(10)),
1676 }
1677 };
1678 assert!(
1679 !persisted.is_empty(),
1680 "svm_locker.get_bundle(bundle_id) should not be empty"
1681 );
1682 assert_eq!(
1683 persisted, expected_sigs,
1684 "Persisted bundle signatures should match locally built signatures"
1685 );
1686
1687 let started = std::time::Instant::now();
1688 let timeout = std::time::Duration::from_secs(2);
1689 let (bundle, context_slot) = loop {
1690 let response = setup
1691 .rpc
1692 .get_bundle_statuses(Some(setup.context.clone()), vec![bundle_id.clone()])
1693 .await
1694 .expect("getBundleStatuses should succeed");
1695
1696 assert_eq!(
1697 response.value.len(),
1698 1,
1699 "getBundleStatuses should return a single status entry per requested id"
1700 );
1701
1702 let context_slot = response.context.slot;
1703 let bundle = response
1704 .value
1705 .into_iter()
1706 .next()
1707 .unwrap()
1708 .expect("bundle should exist locally after sendBundle");
1709 if bundle.slot != 0 {
1710 break (bundle, context_slot);
1711 }
1712
1713 if started.elapsed() > timeout {
1714 break (bundle, context_slot);
1715 }
1716
1717 std::thread::sleep(std::time::Duration::from_millis(10));
1718 };
1719
1720 assert!(
1721 context_slot >= bundle.slot,
1722 "response.context.slot ({}) should be >= bundle.slot ({}); \
1723 getBundleStatuses must surface the same context slot as the \
1724 underlying getSignatureStatuses call",
1725 context_slot,
1726 bundle.slot,
1727 );
1728
1729 assert_eq!(bundle.bundle_id, bundle_id, "bundle_id should match");
1730 assert_eq!(
1731 bundle.transactions, expected_sigs,
1732 "transactions should match bundle signatures"
1733 );
1734 assert!(
1735 matches!(
1736 bundle.confirmation_status,
1737 SolanaTxConfirmationStatus::Processed
1738 | SolanaTxConfirmationStatus::Confirmed
1739 | SolanaTxConfirmationStatus::Finalized
1740 ),
1741 "confirmation_status should be a valid Solana status"
1742 );
1743 assert!(bundle.err.is_ok(), "err should be Ok for successful bundle");
1744 }
1745
1746 #[tokio::test(flavor = "multi_thread")]
1747 async fn test_get_bundle_statuses_multi_transaction_bundle() {
1748 let payer = Keypair::new();
1749 let recipient1 = Pubkey::new_unique();
1750 let recipient2 = Pubkey::new_unique();
1751 let setup = TestSetup::new(SurfpoolJitoRpc);
1752 let recent_blockhash = setup
1753 .context
1754 .svm_locker
1755 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1756
1757 let _ = setup
1758 .context
1759 .svm_locker
1760 .0
1761 .write()
1762 .await
1763 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
1764
1765 let tx1 = build_v0_transaction(
1766 &payer.pubkey(),
1767 &[&payer],
1768 &[system_instruction::transfer(
1769 &payer.pubkey(),
1770 &recipient1,
1771 LAMPORTS_PER_SOL,
1772 )],
1773 &recent_blockhash,
1774 );
1775 let tx2 = build_v0_transaction(
1776 &payer.pubkey(),
1777 &[&payer],
1778 &[system_instruction::transfer(
1779 &payer.pubkey(),
1780 &recipient2,
1781 LAMPORTS_PER_SOL,
1782 )],
1783 &recent_blockhash,
1784 );
1785
1786 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
1787 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
1788 let expected_sigs = vec![tx1.signatures[0].to_string(), tx2.signatures[0].to_string()];
1789
1790 let bundle_id = setup
1791 .rpc
1792 .send_bundle(
1793 Some(setup.context.clone()),
1794 vec![tx1_encoded, tx2_encoded],
1795 None,
1796 )
1797 .await
1798 .expect("sendBundle should succeed for a valid 2-tx bundle");
1799
1800 let response = setup
1801 .rpc
1802 .get_bundle_statuses(Some(setup.context.clone()), vec![bundle_id.clone()])
1803 .await
1804 .expect("getBundleStatuses should succeed");
1805
1806 assert_eq!(
1809 response.value.len(),
1810 1,
1811 "value array must have one entry per requested bundle id"
1812 );
1813 let bundle = response
1814 .value
1815 .into_iter()
1816 .next()
1817 .unwrap()
1818 .expect("bundle should exist locally after sendBundle");
1819 assert_eq!(bundle.bundle_id, bundle_id);
1820 assert_eq!(
1821 bundle.transactions, expected_sigs,
1822 "transactions must preserve submission order across all txs in the bundle"
1823 );
1824 assert!(
1825 bundle.err.is_ok(),
1826 "successful multi-tx bundle should report Ok"
1827 );
1828 assert!(
1829 matches!(
1830 bundle.confirmation_status,
1831 SolanaTxConfirmationStatus::Processed
1832 | SolanaTxConfirmationStatus::Confirmed
1833 | SolanaTxConfirmationStatus::Finalized
1834 ),
1835 "confirmation_status should be a valid Solana status"
1836 );
1837 }
1838
1839 #[test]
1840 fn test_jito_bundle_status_json_shape() {
1841 use solana_transaction_error::TransactionError;
1842
1843 let ok_status = JitoBundleStatus {
1846 bundle_id: "abc123".to_string(),
1847 transactions: vec!["sig1".to_string(), "sig2".to_string()],
1848 slot: 42,
1849 confirmation_status: SolanaTxConfirmationStatus::Finalized,
1850 err: Ok(()),
1851 };
1852 let json = serde_json::to_value(&ok_status).expect("JitoBundleStatus should serialize");
1853
1854 assert!(
1855 json.get("bundle_id").is_some(),
1856 "expected snake_case `bundle_id` field, got: {json}"
1857 );
1858 assert!(json.get("transactions").is_some());
1859 assert!(json.get("slot").is_some());
1860 assert!(
1861 json.get("confirmationStatus").is_some(),
1862 "expected snake_case `confirmationStatus` field, got: {json}"
1863 );
1864 assert!(json.get("err").is_some());
1865
1866 assert!(
1867 json.get("bundleId").is_none(),
1868 "camelCase `bundleId` should not be serialized (Jito uses snake_case on the wire)"
1869 );
1870 assert!(
1871 json.get("confirmation_status").is_none(),
1872 "camelCase `confirmation_status` should not be serialized"
1873 );
1874
1875 assert_eq!(
1877 json.get("err"),
1878 Some(&serde_json::json!({ "Ok": null })),
1879 "Ok variant of err should serialize as {{\"Ok\": null}}"
1880 );
1881 assert_eq!(json.get("bundle_id").unwrap().as_str(), Some("abc123"));
1882 assert_eq!(json.get("slot").unwrap().as_u64(), Some(42));
1883 assert_eq!(
1884 json.get("confirmationStatus").unwrap().as_str(),
1885 Some("finalized"),
1886 "confirmationStatus should serialize as a lowercase string"
1887 );
1888
1889 let err_status = JitoBundleStatus {
1891 bundle_id: "abc123".to_string(),
1892 transactions: vec!["sig1".to_string()],
1893 slot: 7,
1894 confirmation_status: SolanaTxConfirmationStatus::Processed,
1895 err: Err(TransactionError::AccountNotFound),
1896 };
1897 let err_json = serde_json::to_value(&err_status).expect("err variant should serialize");
1898 let err_field = err_json.get("err").expect("err field should be present");
1899 assert!(
1900 err_field.get("Err").is_some(),
1901 "Err variant of err should serialize as {{\"Err\": ...}}, got: {err_field}"
1902 );
1903
1904 let round_tripped: JitoBundleStatus =
1906 serde_json::from_value(json).expect("JitoBundleStatus should round-trip");
1907 assert_eq!(round_tripped, ok_status);
1908 }
1909
1910 #[tokio::test(flavor = "multi_thread")]
1911 async fn test_get_bundle_statuses_batched_known_and_unknown() {
1912 let payer = Keypair::new();
1916 let recipient = Pubkey::new_unique();
1917 let setup = TestSetup::new(SurfpoolJitoRpc);
1918 let recent_blockhash = setup
1919 .context
1920 .svm_locker
1921 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1922
1923 let _ = setup
1924 .context
1925 .svm_locker
1926 .0
1927 .write()
1928 .await
1929 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
1930
1931 let tx = build_v0_transaction(
1932 &payer.pubkey(),
1933 &[&payer],
1934 &[system_instruction::transfer(
1935 &payer.pubkey(),
1936 &recipient,
1937 LAMPORTS_PER_SOL,
1938 )],
1939 &recent_blockhash,
1940 );
1941 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
1942
1943 let known_id = setup
1944 .rpc
1945 .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
1946 .await
1947 .expect("sendBundle should succeed");
1948 let unknown_id = "f".repeat(64);
1949
1950 let response = setup
1953 .rpc
1954 .get_bundle_statuses(
1955 Some(setup.context.clone()),
1956 vec![unknown_id.clone(), known_id.clone()],
1957 )
1958 .await
1959 .expect("getBundleStatuses should succeed");
1960
1961 assert_eq!(
1962 response.value.len(),
1963 2,
1964 "value must have exactly one entry per requested bundle id"
1965 );
1966 assert!(
1967 response.value[0].is_none(),
1968 "index 0 (unknown id) should be null"
1969 );
1970 let known = response.value[1]
1971 .as_ref()
1972 .expect("index 1 (known id) should be Some(JitoBundleStatus)");
1973 assert_eq!(known.bundle_id, known_id);
1974 }
1975
1976 fn make_transfer_b64(
1982 payer: &Keypair,
1983 recipient: &Pubkey,
1984 lamports: u64,
1985 recent_blockhash: &solana_hash::Hash,
1986 ) -> String {
1987 let tx = build_v0_transaction(
1988 &payer.pubkey(),
1989 &[payer],
1990 &[system_instruction::transfer(
1991 &payer.pubkey(),
1992 recipient,
1993 lamports,
1994 )],
1995 recent_blockhash,
1996 );
1997 use base64::Engine;
1998 base64::engine::general_purpose::STANDARD.encode(bincode::serialize(&tx).unwrap())
1999 }
2000
2001 #[tokio::test(flavor = "multi_thread")]
2002 async fn test_simulate_bundle_empty_rejected() {
2003 let setup = TestSetup::new(SurfpoolJitoRpc);
2004 let result = setup
2005 .rpc
2006 .simulate_bundle(
2007 Some(setup.context.clone()),
2008 RpcBundleRequest {
2009 encoded_transactions: vec![],
2010 },
2011 None,
2012 )
2013 .await;
2014 assert!(result.is_err(), "empty bundle should be rejected");
2015 assert!(
2016 result
2017 .unwrap_err()
2018 .message
2019 .contains("Bundle cannot be empty"),
2020 "expected empty-bundle error message"
2021 );
2022 }
2023
2024 #[tokio::test(flavor = "multi_thread")]
2025 async fn test_simulate_bundle_exceeds_max_size_rejected() {
2026 let setup = TestSetup::new(SurfpoolJitoRpc);
2027 let result = setup
2028 .rpc
2029 .simulate_bundle(
2030 Some(setup.context.clone()),
2031 RpcBundleRequest {
2032 encoded_transactions: vec!["tx".to_string(); MAX_BUNDLE_SIZE + 1],
2033 },
2034 None,
2035 )
2036 .await;
2037 assert!(result.is_err());
2038 assert!(
2039 result.unwrap_err().message.contains("exceeds maximum size"),
2040 "expected max-size error message"
2041 );
2042 }
2043
2044 #[tokio::test(flavor = "multi_thread")]
2045 async fn test_simulate_bundle_no_context_returns_unhealthy() {
2046 let setup = TestSetup::new(SurfpoolJitoRpc);
2047 let result = setup
2048 .rpc
2049 .simulate_bundle(
2050 None,
2051 RpcBundleRequest {
2052 encoded_transactions: vec!["x".to_string()],
2053 },
2054 None,
2055 )
2056 .await;
2057 assert!(result.is_err(), "missing meta should yield NodeUnhealthy");
2058 }
2059
2060 #[tokio::test(flavor = "multi_thread")]
2061 async fn test_simulate_bundle_replace_blockhash_requires_skip_sig_verify() {
2062 let setup = TestSetup::new(SurfpoolJitoRpc);
2063 let cfg = RpcSimulateBundleConfig {
2064 pre_execution_accounts_configs: vec![None],
2065 post_execution_accounts_configs: vec![None],
2066 transaction_encoding: Some(UiTransactionEncoding::Base64),
2067 simulation_bank: None,
2068 skip_sig_verify: false,
2069 replace_recent_blockhash: true,
2070 };
2071 let result = setup
2072 .rpc
2073 .simulate_bundle(
2074 Some(setup.context.clone()),
2075 RpcBundleRequest {
2076 encoded_transactions: vec!["x".to_string()],
2077 },
2078 Some(cfg),
2079 )
2080 .await;
2081 assert!(
2082 result.is_err(),
2083 "replace_recent_blockhash + sig verify should be rejected"
2084 );
2085 assert!(
2086 result
2087 .unwrap_err()
2088 .message
2089 .contains("replaceRecentBlockhash requires skipSigVerify=true"),
2090 "expected explicit camelCase JSON error about the dangerous combination"
2091 );
2092 }
2093
2094 #[tokio::test(flavor = "multi_thread")]
2095 async fn test_simulate_bundle_pre_post_lengths_must_match() {
2096 let setup = TestSetup::new(SurfpoolJitoRpc);
2097 let cfg = RpcSimulateBundleConfig {
2098 pre_execution_accounts_configs: vec![None],
2100 post_execution_accounts_configs: vec![None, None],
2101 transaction_encoding: Some(UiTransactionEncoding::Base64),
2102 simulation_bank: None,
2103 skip_sig_verify: true,
2104 replace_recent_blockhash: false,
2105 };
2106 let result = setup
2107 .rpc
2108 .simulate_bundle(
2109 Some(setup.context.clone()),
2110 RpcBundleRequest {
2111 encoded_transactions: vec!["a".to_string(), "b".to_string()],
2112 },
2113 Some(cfg),
2114 )
2115 .await;
2116 assert!(result.is_err());
2117 assert!(
2118 result
2119 .unwrap_err()
2120 .message
2121 .contains("must be equal in length"),
2122 "expected length-mismatch error"
2123 );
2124 }
2125
2126 #[tokio::test(flavor = "multi_thread")]
2127 async fn test_simulate_bundle_succeeds_does_not_mutate_live_vm() {
2128 let payer = Keypair::new();
2129 let recipient = Pubkey::new_unique();
2130 let setup = TestSetup::new(SurfpoolJitoRpc);
2131 let recent_blockhash = setup
2132 .context
2133 .svm_locker
2134 .with_svm_reader(|svm| svm.latest_blockhash());
2135
2136 let _ = setup
2139 .context
2140 .svm_locker
2141 .0
2142 .write()
2143 .await
2144 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
2145
2146 let tx_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2147
2148 let recipient_pre = setup
2150 .context
2151 .svm_locker
2152 .with_svm_reader(|svm| svm.get_account(&recipient))
2153 .ok()
2154 .flatten()
2155 .map(|a| a.lamports)
2156 .unwrap_or(0);
2157 assert_eq!(recipient_pre, 0, "recipient should start at 0 lamports");
2158
2159 let cfg = RpcSimulateBundleConfig {
2160 pre_execution_accounts_configs: vec![Some(
2161 surfpool_types::RpcSimulateTransactionAccountsConfig {
2162 encoding: Some(UiAccountEncoding::Base64),
2163 addresses: vec![recipient.to_string()],
2164 },
2165 )],
2166 post_execution_accounts_configs: vec![Some(
2167 surfpool_types::RpcSimulateTransactionAccountsConfig {
2168 encoding: Some(UiAccountEncoding::Base64),
2169 addresses: vec![recipient.to_string()],
2170 },
2171 )],
2172 transaction_encoding: Some(UiTransactionEncoding::Base64),
2173 simulation_bank: None,
2174 skip_sig_verify: false,
2175 replace_recent_blockhash: false,
2176 };
2177
2178 let response = setup
2179 .rpc
2180 .simulate_bundle(
2181 Some(setup.context.clone()),
2182 RpcBundleRequest {
2183 encoded_transactions: vec![tx_b64],
2184 },
2185 Some(cfg),
2186 )
2187 .await
2188 .expect("simulate_bundle should not return a JSON-RPC error");
2189
2190 match response.value.summary {
2192 RpcBundleSimulationSummary::Succeeded => {}
2193 other => panic!("expected Succeeded summary, got {:?}", other),
2194 }
2195 assert_eq!(response.value.transaction_results.len(), 1);
2196 let result = &response.value.transaction_results[0];
2197 assert!(result.err.is_none(), "tx should not have errored");
2198 assert!(
2199 result.units_consumed.is_some(),
2200 "units_consumed should be populated"
2201 );
2202 let pre = result
2203 .pre_execution_accounts
2204 .as_ref()
2205 .expect("pre_execution_accounts requested");
2206 let post = result
2207 .post_execution_accounts
2208 .as_ref()
2209 .expect("post_execution_accounts requested");
2210 assert_eq!(pre.len(), 1);
2211 assert_eq!(post.len(), 1);
2212 assert_eq!(pre[0].lamports, 0, "recipient pre = 0");
2213 assert_eq!(
2214 post[0].lamports, LAMPORTS_PER_SOL,
2215 "recipient post = transferred amount"
2216 );
2217
2218 let recipient_after_sim = setup
2220 .context
2221 .svm_locker
2222 .with_svm_reader(|svm| svm.get_account(&recipient))
2223 .ok()
2224 .flatten()
2225 .map(|a| a.lamports)
2226 .unwrap_or(0);
2227 assert_eq!(
2228 recipient_after_sim, 0,
2229 "live VM must be untouched after simulate_bundle"
2230 );
2231 }
2232
2233 #[tokio::test(flavor = "multi_thread")]
2234 async fn test_simulate_bundle_failure_marks_summary_and_skips_remaining() {
2235 let payer = Keypair::new();
2236 let recipient = Pubkey::new_unique();
2237 let setup = TestSetup::new(SurfpoolJitoRpc);
2238 let recent_blockhash = setup
2239 .context
2240 .svm_locker
2241 .with_svm_reader(|svm| svm.latest_blockhash());
2242
2243 let _ = setup
2249 .context
2250 .svm_locker
2251 .0
2252 .write()
2253 .await
2254 .airdrop(&payer.pubkey(), LAMPORTS_PER_SOL / 2);
2255
2256 let tx1_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2257 let tx2_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2258
2259 let response = setup
2260 .rpc
2261 .simulate_bundle(
2262 Some(setup.context.clone()),
2263 RpcBundleRequest {
2264 encoded_transactions: vec![tx1_b64, tx2_b64],
2265 },
2266 None,
2267 )
2268 .await
2269 .expect("simulate_bundle should return Ok response (failure encoded in summary)");
2270
2271 match &response.value.summary {
2272 RpcBundleSimulationSummary::Failed { tx_signature, .. } => {
2273 assert!(
2274 tx_signature.is_some(),
2275 "Failed summary should carry the offending tx signature"
2276 );
2277 }
2278 RpcBundleSimulationSummary::Succeeded => {
2279 panic!("bundle should have failed (insufficient funds)");
2280 }
2281 }
2282 assert_eq!(response.value.transaction_results.len(), 2);
2283 assert!(
2284 response.value.transaction_results[0].err.is_some(),
2285 "first tx should have errored"
2286 );
2287 assert!(
2288 response.value.transaction_results[1].err.is_none()
2289 && response.value.transaction_results[1].logs.is_none(),
2290 "second tx should be in skipped (empty) state — never simulated"
2291 );
2292 }
2293
2294 #[tokio::test(flavor = "multi_thread")]
2299 async fn test_simulate_bundle_null_account_configs_yield_none_not_empty_array() {
2300 let payer = Keypair::new();
2301 let recipient = Pubkey::new_unique();
2302 let setup = TestSetup::new(SurfpoolJitoRpc);
2303 let recent_blockhash = setup
2304 .context
2305 .svm_locker
2306 .with_svm_reader(|svm| svm.latest_blockhash());
2307
2308 let _ = setup
2309 .context
2310 .svm_locker
2311 .0
2312 .write()
2313 .await
2314 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
2315
2316 let tx_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2317
2318 let cfg = RpcSimulateBundleConfig {
2320 pre_execution_accounts_configs: vec![None],
2321 post_execution_accounts_configs: vec![None],
2322 transaction_encoding: Some(UiTransactionEncoding::Base64),
2323 simulation_bank: None,
2324 skip_sig_verify: false,
2325 replace_recent_blockhash: false,
2326 };
2327
2328 let response = setup
2329 .rpc
2330 .simulate_bundle(
2331 Some(setup.context.clone()),
2332 RpcBundleRequest {
2333 encoded_transactions: vec![tx_b64],
2334 },
2335 Some(cfg),
2336 )
2337 .await
2338 .expect("simulate_bundle should not error");
2339
2340 let result = &response.value.transaction_results[0];
2341 assert!(
2342 result.pre_execution_accounts.is_none(),
2343 "pre_execution_accounts must be None when config entry is None, got {:?}",
2344 result.pre_execution_accounts,
2345 );
2346 assert!(
2347 result.post_execution_accounts.is_none(),
2348 "post_execution_accounts must be None when config entry is None, got {:?}",
2349 result.post_execution_accounts,
2350 );
2351 }
2352
2353 #[tokio::test(flavor = "multi_thread")]
2357 async fn test_simulate_bundle_empty_addresses_yield_some_empty_vec() {
2358 let payer = Keypair::new();
2359 let recipient = Pubkey::new_unique();
2360 let setup = TestSetup::new(SurfpoolJitoRpc);
2361 let recent_blockhash = setup
2362 .context
2363 .svm_locker
2364 .with_svm_reader(|svm| svm.latest_blockhash());
2365
2366 let _ = setup
2367 .context
2368 .svm_locker
2369 .0
2370 .write()
2371 .await
2372 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
2373
2374 let tx_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2375
2376 let cfg = RpcSimulateBundleConfig {
2378 pre_execution_accounts_configs: vec![Some(
2379 surfpool_types::RpcSimulateTransactionAccountsConfig {
2380 encoding: Some(UiAccountEncoding::Base64),
2381 addresses: vec![],
2382 },
2383 )],
2384 post_execution_accounts_configs: vec![Some(
2385 surfpool_types::RpcSimulateTransactionAccountsConfig {
2386 encoding: Some(UiAccountEncoding::Base64),
2387 addresses: vec![],
2388 },
2389 )],
2390 transaction_encoding: Some(UiTransactionEncoding::Base64),
2391 simulation_bank: None,
2392 skip_sig_verify: false,
2393 replace_recent_blockhash: false,
2394 };
2395
2396 let response = setup
2397 .rpc
2398 .simulate_bundle(
2399 Some(setup.context.clone()),
2400 RpcBundleRequest {
2401 encoded_transactions: vec![tx_b64],
2402 },
2403 Some(cfg),
2404 )
2405 .await
2406 .expect("simulate_bundle should not error");
2407
2408 let result = &response.value.transaction_results[0];
2409 assert_eq!(
2410 result.pre_execution_accounts.as_deref(),
2411 Some(&[][..]),
2412 "pre_execution_accounts must be Some(empty) when config addresses is explicitly []",
2413 );
2414 assert_eq!(
2415 result.post_execution_accounts.as_deref(),
2416 Some(&[][..]),
2417 "post_execution_accounts must be Some(empty) when config addresses is explicitly []",
2418 );
2419 }
2420
2421 #[tokio::test(flavor = "multi_thread")]
2425 async fn test_simulate_bundle_rejects_non_base64_encoding() {
2426 let setup = TestSetup::new(SurfpoolJitoRpc);
2427 let cfg = RpcSimulateBundleConfig {
2428 pre_execution_accounts_configs: vec![None],
2429 post_execution_accounts_configs: vec![None],
2430 transaction_encoding: Some(UiTransactionEncoding::Base58),
2431 simulation_bank: None,
2432 skip_sig_verify: true,
2433 replace_recent_blockhash: false,
2434 };
2435 let result = setup
2436 .rpc
2437 .simulate_bundle(
2438 Some(setup.context.clone()),
2439 RpcBundleRequest {
2440 encoded_transactions: vec!["x".to_string()],
2441 },
2442 Some(cfg),
2443 )
2444 .await;
2445 assert!(result.is_err(), "base58 encoding must be rejected");
2446 assert!(
2447 result
2448 .unwrap_err()
2449 .message
2450 .contains("Base64 is the only supported encoding"),
2451 "expected explicit error about base64-only enforcement"
2452 );
2453 }
2454
2455 #[tokio::test(flavor = "multi_thread")]
2462 async fn test_simulate_bundle_propagates_typed_execution_error() {
2463 let payer = Keypair::new();
2464 let recipient = Pubkey::new_unique();
2465 let setup = TestSetup::new(SurfpoolJitoRpc);
2466 let recent_blockhash = setup
2467 .context
2468 .svm_locker
2469 .with_svm_reader(|svm| svm.latest_blockhash());
2470
2471 let _ = setup
2474 .context
2475 .svm_locker
2476 .0
2477 .write()
2478 .await
2479 .airdrop(&payer.pubkey(), 1_000); let tx_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2482
2483 let response = setup
2484 .rpc
2485 .simulate_bundle(
2486 Some(setup.context.clone()),
2487 RpcBundleRequest {
2488 encoded_transactions: vec![tx_b64],
2489 },
2490 None,
2491 )
2492 .await
2493 .expect("simulate_bundle should return Ok with a Failed summary");
2494
2495 match &response.value.summary {
2497 RpcBundleSimulationSummary::Failed { tx_signature, .. } => {
2498 assert!(
2499 tx_signature.is_some(),
2500 "Failed summary must carry signature"
2501 );
2502 }
2503 other => panic!("expected Failed summary, got {:?}", other),
2504 }
2505
2506 let err = response.value.transaction_results[0]
2507 .err
2508 .as_ref()
2509 .expect("err must be populated for a failed tx");
2510 assert!(
2517 !matches!(
2518 err,
2519 solana_transaction_error::TransactionError::SanitizeFailure
2520 ),
2521 "execution failure must surface a typed err, not SanitizeFailure (got {:?})",
2522 err,
2523 );
2524 }
2525
2526 #[tokio::test(flavor = "multi_thread")]
2535 async fn test_simulate_bundle_propagates_typed_signature_failure() {
2536 let payer = Keypair::new();
2537 let recipient = Pubkey::new_unique();
2538 let setup = TestSetup::new(SurfpoolJitoRpc);
2539 let recent_blockhash = setup
2540 .context
2541 .svm_locker
2542 .with_svm_reader(|svm| svm.latest_blockhash());
2543
2544 let mut tx = build_v0_transaction(
2548 &payer.pubkey(),
2549 &[&payer],
2550 &[system_instruction::transfer(
2551 &payer.pubkey(),
2552 &recipient,
2553 1_000,
2554 )],
2555 &recent_blockhash,
2556 );
2557 let mut sig_bytes = tx.signatures[0].as_ref().to_vec();
2558 sig_bytes[0] = sig_bytes[0].wrapping_add(1);
2559 tx.signatures[0] = Signature::try_from(sig_bytes.as_slice()).unwrap();
2560
2561 use base64::Engine;
2562 let tx_b64 =
2563 base64::engine::general_purpose::STANDARD.encode(bincode::serialize(&tx).unwrap());
2564
2565 let response = setup
2566 .rpc
2567 .simulate_bundle(
2568 Some(setup.context.clone()),
2569 RpcBundleRequest {
2570 encoded_transactions: vec![tx_b64],
2571 },
2572 Some(RpcSimulateBundleConfig {
2573 pre_execution_accounts_configs: vec![None],
2574 post_execution_accounts_configs: vec![None],
2575 transaction_encoding: Some(UiTransactionEncoding::Base64),
2576 simulation_bank: None,
2577 skip_sig_verify: false,
2579 replace_recent_blockhash: false,
2580 }),
2581 )
2582 .await
2583 .expect("simulate_bundle should return Ok with a Failed summary");
2584
2585 match &response.value.summary {
2586 RpcBundleSimulationSummary::Failed { .. } => {}
2587 other => panic!("expected Failed summary, got {:?}", other),
2588 }
2589
2590 let err = response.value.transaction_results[0]
2591 .err
2592 .as_ref()
2593 .expect("err must be populated for a sig-verify failure");
2594 assert!(
2595 matches!(
2596 err,
2597 solana_transaction_error::TransactionError::SignatureFailure
2598 ),
2599 "sig-verify failure must surface SignatureFailure (got {:?})",
2600 err,
2601 );
2602 }
2603
2604 #[tokio::test(flavor = "multi_thread")]
2611 async fn test_simulate_bundle_rejects_sigless_tx() {
2612 let setup = TestSetup::new(SurfpoolJitoRpc);
2613 let recent_blockhash = setup
2614 .context
2615 .svm_locker
2616 .with_svm_reader(|svm| svm.latest_blockhash());
2617
2618 let payer = Keypair::new();
2622 let recipient = Pubkey::new_unique();
2623 let msg = VersionedMessage::V0(
2624 V0Message::try_compile(
2625 &payer.pubkey(),
2626 &[system_instruction::transfer(
2627 &payer.pubkey(),
2628 &recipient,
2629 1_000,
2630 )],
2631 &[],
2632 recent_blockhash,
2633 )
2634 .unwrap(),
2635 );
2636 let tx = VersionedTransaction {
2637 signatures: vec![], message: msg,
2639 };
2640 use base64::Engine;
2641 let tx_b64 =
2642 base64::engine::general_purpose::STANDARD.encode(bincode::serialize(&tx).unwrap());
2643
2644 let result = setup
2645 .rpc
2646 .simulate_bundle(
2647 Some(setup.context.clone()),
2648 RpcBundleRequest {
2649 encoded_transactions: vec![tx_b64],
2650 },
2651 None,
2652 )
2653 .await;
2654
2655 assert!(
2656 result.is_err(),
2657 "sig-less tx must be rejected at decode time"
2658 );
2659 let err = result.unwrap_err();
2660 assert!(
2661 err.message.contains("has no signatures"),
2662 "expected explicit no-signatures rejection (got {})",
2663 err.message,
2664 );
2665 }
2666
2667 #[tokio::test(flavor = "multi_thread")]
2675 async fn test_simulate_bundle_accepts_partial_config_omitting_account_configs() {
2676 let payer = Keypair::new();
2677 let recipient = Pubkey::new_unique();
2678 let setup = TestSetup::new(SurfpoolJitoRpc);
2679 let recent_blockhash = setup
2680 .context
2681 .svm_locker
2682 .with_svm_reader(|svm| svm.latest_blockhash());
2683
2684 let _ = setup
2685 .context
2686 .svm_locker
2687 .0
2688 .write()
2689 .await
2690 .airdrop(&payer.pubkey(), 10 * LAMPORTS_PER_SOL);
2691
2692 let tx_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2695
2696 let cfg = RpcSimulateBundleConfig {
2700 pre_execution_accounts_configs: vec![],
2701 post_execution_accounts_configs: vec![],
2702 transaction_encoding: Some(UiTransactionEncoding::Base64),
2703 simulation_bank: None,
2704 skip_sig_verify: true,
2705 replace_recent_blockhash: false,
2706 };
2707
2708 let response = setup
2709 .rpc
2710 .simulate_bundle(
2711 Some(setup.context.clone()),
2712 RpcBundleRequest {
2713 encoded_transactions: vec![tx_b64],
2714 },
2715 Some(cfg),
2716 )
2717 .await
2718 .expect("partial config must be accepted");
2719
2720 match &response.value.summary {
2721 RpcBundleSimulationSummary::Succeeded => {}
2722 other => panic!("expected Succeeded summary, got {:?}", other),
2723 }
2724 let result = &response.value.transaction_results[0];
2725 assert!(
2726 result.pre_execution_accounts.is_none(),
2727 "omitted pre config must yield None (got {:?})",
2728 result.pre_execution_accounts,
2729 );
2730 assert!(
2731 result.post_execution_accounts.is_none(),
2732 "omitted post config must yield None (got {:?})",
2733 result.post_execution_accounts,
2734 );
2735 }
2736}