use std::str::FromStr;
use jsonrpc_core::{Error, Result};
use jsonrpc_derive::rpc;
use sha2::{Digest, Sha256};
use solana_client::{rpc_config::RpcSendTransactionConfig, rpc_custom_error::RpcCustomError};
use solana_signature::Signature;
use super::{
RunloopContext,
full::{Full, SurfpoolFullRpc, SurfpoolRpcSendTransactionConfig},
};
const MAX_BUNDLE_SIZE: usize = 5;
#[rpc]
pub trait Jito {
type Metadata;
#[rpc(meta, name = "sendBundle")]
fn send_bundle(
&self,
meta: Self::Metadata,
transactions: Vec<String>,
config: Option<RpcSendTransactionConfig>,
) -> Result<String>;
}
#[derive(Clone)]
pub struct SurfpoolJitoRpc;
impl Jito for SurfpoolJitoRpc {
type Metadata = Option<RunloopContext>;
fn send_bundle(
&self,
meta: Self::Metadata,
transactions: Vec<String>,
config: Option<RpcSendTransactionConfig>,
) -> Result<String> {
if transactions.is_empty() {
return Err(Error::invalid_params("Bundle cannot be empty"));
}
if transactions.len() > MAX_BUNDLE_SIZE {
return Err(Error::invalid_params(format!(
"Bundle exceeds maximum size of {MAX_BUNDLE_SIZE} transactions"
)));
}
let Some(_ctx) = &meta else {
return Err(RpcCustomError::NodeUnhealthy {
num_slots_behind: None,
}
.into());
};
let full_rpc = SurfpoolFullRpc;
let mut bundle_signatures = Vec::new();
let base_config = config.unwrap_or_default();
for (idx, tx_data) in transactions.iter().enumerate() {
let bundle_config = Some(SurfpoolRpcSendTransactionConfig {
base: RpcSendTransactionConfig {
skip_preflight: true,
..base_config.clone()
},
skip_sig_verify: None,
});
match full_rpc.send_transaction(meta.clone(), tx_data.clone(), bundle_config) {
Ok(signature_str) => {
let signature = Signature::from_str(&signature_str).map_err(|e| {
Error::invalid_params(format!("Failed to parse signature: {e}"))
})?;
bundle_signatures.push(signature);
}
Err(e) => {
return Err(Error {
code: e.code,
message: format!("Bundle transaction {} failed: {}", idx, e.message),
data: e.data,
});
}
}
}
let concatenated_signatures = bundle_signatures
.iter()
.map(|sig| sig.to_string())
.collect::<Vec<_>>()
.join(",");
let mut hasher = Sha256::new();
hasher.update(concatenated_signatures.as_bytes());
let bundle_id = hasher.finalize();
Ok(hex::encode(bundle_id))
}
}
#[cfg(test)]
mod tests {
use sha2::{Digest, Sha256};
use solana_keypair::Keypair;
use solana_message::{VersionedMessage, v0::Message as V0Message};
use solana_pubkey::Pubkey;
use solana_signer::Signer;
use solana_system_interface::instruction as system_instruction;
use solana_transaction::versioned::VersionedTransaction;
use surfpool_types::{SimnetCommand, TransactionConfirmationStatus, TransactionStatusEvent};
use super::*;
use crate::{
tests::helpers::TestSetup,
types::{SurfnetTransactionStatus, TransactionWithStatusMeta},
};
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
fn build_v0_transaction(
payer: &Pubkey,
signers: &[&Keypair],
instructions: &[solana_instruction::Instruction],
recent_blockhash: &solana_hash::Hash,
) -> VersionedTransaction {
let msg = VersionedMessage::V0(
V0Message::try_compile(payer, instructions, &[], *recent_blockhash).unwrap(),
);
VersionedTransaction::try_new(msg, signers).unwrap()
}
#[test]
fn test_send_bundle_empty_bundle_rejected() {
let setup = TestSetup::new(SurfpoolJitoRpc);
let result = setup.rpc.send_bundle(Some(setup.context), vec![], None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.message.contains("Bundle cannot be empty"),
"Expected 'Bundle cannot be empty' error, got: {}",
err.message
);
}
#[test]
fn test_send_bundle_exceeds_max_size_rejected() {
let setup = TestSetup::new(SurfpoolJitoRpc);
let transactions = vec!["tx".to_string(); MAX_BUNDLE_SIZE + 1];
let result = setup
.rpc
.send_bundle(Some(setup.context), transactions, None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.message.contains("exceeds maximum size"),
"Expected max size error, got: {}",
err.message
);
}
#[test]
fn test_send_bundle_no_context_returns_unhealthy() {
let setup = TestSetup::new(SurfpoolJitoRpc);
let result = setup
.rpc
.send_bundle(None, vec!["some_tx".to_string()], None);
assert!(result.is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn test_send_bundle_single_transaction() {
let payer = Keypair::new();
let recipient = Pubkey::new_unique();
let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
let recent_blockhash = setup
.context
.svm_locker
.with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
let _ = setup
.context
.svm_locker
.0
.write()
.await
.airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
let tx = build_v0_transaction(
&payer.pubkey(),
&[&payer],
&[system_instruction::transfer(
&payer.pubkey(),
&recipient,
LAMPORTS_PER_SOL,
)],
&recent_blockhash,
);
let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
let expected_sig = tx.signatures[0];
let setup_clone = setup.clone();
let handle = hiro_system_kit::thread_named("send_bundle")
.spawn(move || {
setup_clone
.rpc
.send_bundle(Some(setup_clone.context), vec![tx_encoded], None)
})
.unwrap();
loop {
match mempool_rx.recv() {
Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => {
let mut writer = setup.context.svm_locker.0.write().await;
let slot = writer.get_latest_absolute_slot();
writer.transactions_queued_for_confirmation.push_back((
tx.clone(),
status_tx.clone(),
None,
));
let sig = tx.signatures[0];
let tx_with_status_meta = TransactionWithStatusMeta {
slot,
transaction: tx,
..Default::default()
};
let mutated_accounts = std::collections::HashSet::new();
writer
.transactions
.store(
sig.to_string(),
SurfnetTransactionStatus::processed(
tx_with_status_meta,
mutated_accounts,
),
)
.unwrap();
status_tx
.send(TransactionStatusEvent::Success(
TransactionConfirmationStatus::Confirmed,
))
.unwrap();
break;
}
Ok(SimnetCommand::AirdropProcessed) => continue,
_ => panic!("failed to receive transaction from mempool"),
}
}
let result = handle.join().unwrap();
assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
let bundle_id = result.unwrap();
let mut hasher = Sha256::new();
hasher.update(expected_sig.to_string().as_bytes());
let expected_bundle_id = hex::encode(hasher.finalize());
assert_eq!(
bundle_id, expected_bundle_id,
"Bundle ID should match SHA-256 of signature"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_send_bundle_multiple_transactions() {
let payer = Keypair::new();
let recipient1 = Pubkey::new_unique();
let recipient2 = Pubkey::new_unique();
let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
let recent_blockhash = setup
.context
.svm_locker
.with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
let _ = setup
.context
.svm_locker
.0
.write()
.await
.airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
let tx1 = build_v0_transaction(
&payer.pubkey(),
&[&payer],
&[system_instruction::transfer(
&payer.pubkey(),
&recipient1,
LAMPORTS_PER_SOL,
)],
&recent_blockhash,
);
let tx2 = build_v0_transaction(
&payer.pubkey(),
&[&payer],
&[system_instruction::transfer(
&payer.pubkey(),
&recipient2,
LAMPORTS_PER_SOL,
)],
&recent_blockhash,
);
let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
let expected_sig1 = tx1.signatures[0];
let expected_sig2 = tx2.signatures[0];
let setup_clone = setup.clone();
let handle = hiro_system_kit::thread_named("send_bundle")
.spawn(move || {
setup_clone.rpc.send_bundle(
Some(setup_clone.context),
vec![tx1_encoded, tx2_encoded],
None,
)
})
.unwrap();
let mut processed_count = 0;
while processed_count < 2 {
match mempool_rx.recv() {
Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => {
let mut writer = setup.context.svm_locker.0.write().await;
let slot = writer.get_latest_absolute_slot();
writer.transactions_queued_for_confirmation.push_back((
tx.clone(),
status_tx.clone(),
None,
));
let sig = tx.signatures[0];
let tx_with_status_meta = TransactionWithStatusMeta {
slot,
transaction: tx,
..Default::default()
};
let mutated_accounts = std::collections::HashSet::new();
writer
.transactions
.store(
sig.to_string(),
SurfnetTransactionStatus::processed(
tx_with_status_meta,
mutated_accounts,
),
)
.unwrap();
status_tx
.send(TransactionStatusEvent::Success(
TransactionConfirmationStatus::Confirmed,
))
.unwrap();
processed_count += 1;
}
Ok(SimnetCommand::AirdropProcessed) => continue,
_ => panic!("failed to receive transaction from mempool"),
}
}
let result = handle.join().unwrap();
assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
let bundle_id = result.unwrap();
let concatenated = format!("{},{}", expected_sig1, expected_sig2);
let mut hasher = Sha256::new();
hasher.update(concatenated.as_bytes());
let expected_bundle_id = hex::encode(hasher.finalize());
assert_eq!(
bundle_id, expected_bundle_id,
"Bundle ID should match SHA-256 of comma-separated signatures"
);
}
}