1use std::str::FromStr;
2
3use jsonrpc_core::{Error, Result};
4use jsonrpc_derive::rpc;
5use sha2::{Digest, Sha256};
6use solana_client::{rpc_config::RpcSendTransactionConfig, rpc_custom_error::RpcCustomError};
7use solana_signature::Signature;
8
9use super::{
10 RunloopContext,
11 full::{Full, SurfpoolFullRpc, SurfpoolRpcSendTransactionConfig},
12};
13
14const MAX_BUNDLE_SIZE: usize = 5;
16
17#[rpc]
19pub trait Jito {
20 type Metadata;
21
22 #[rpc(meta, name = "sendBundle")]
56 fn send_bundle(
57 &self,
58 meta: Self::Metadata,
59 transactions: Vec<String>,
60 config: Option<RpcSendTransactionConfig>,
61 ) -> Result<String>;
62}
63
64#[derive(Clone)]
65pub struct SurfpoolJitoRpc;
66
67impl Jito for SurfpoolJitoRpc {
68 type Metadata = Option<RunloopContext>;
69
70 fn send_bundle(
71 &self,
72 meta: Self::Metadata,
73 transactions: Vec<String>,
74 config: Option<RpcSendTransactionConfig>,
75 ) -> Result<String> {
76 if transactions.is_empty() {
77 return Err(Error::invalid_params("Bundle cannot be empty"));
78 }
79
80 if transactions.len() > MAX_BUNDLE_SIZE {
81 return Err(Error::invalid_params(format!(
82 "Bundle exceeds maximum size of {MAX_BUNDLE_SIZE} transactions"
83 )));
84 }
85
86 let Some(_ctx) = &meta else {
87 return Err(RpcCustomError::NodeUnhealthy {
88 num_slots_behind: None,
89 }
90 .into());
91 };
92
93 let full_rpc = SurfpoolFullRpc;
94 let mut bundle_signatures = Vec::new();
95 let base_config = config.unwrap_or_default();
96
97 for (idx, tx_data) in transactions.iter().enumerate() {
102 let bundle_config = Some(SurfpoolRpcSendTransactionConfig {
103 base: RpcSendTransactionConfig {
104 skip_preflight: true,
105 ..base_config.clone()
106 },
107 skip_sig_verify: None,
108 });
109 match full_rpc.send_transaction(meta.clone(), tx_data.clone(), bundle_config) {
111 Ok(signature_str) => {
112 let signature = Signature::from_str(&signature_str).map_err(|e| {
114 Error::invalid_params(format!("Failed to parse signature: {e}"))
115 })?;
116 bundle_signatures.push(signature);
117 }
118 Err(e) => {
119 return Err(Error {
121 code: e.code,
122 message: format!("Bundle transaction {} failed: {}", idx, e.message),
123 data: e.data,
124 });
125 }
126 }
127 }
128
129 let concatenated_signatures = bundle_signatures
132 .iter()
133 .map(|sig| sig.to_string())
134 .collect::<Vec<_>>()
135 .join(",");
136 let mut hasher = Sha256::new();
137 hasher.update(concatenated_signatures.as_bytes());
138 let bundle_id = hasher.finalize();
139 Ok(hex::encode(bundle_id))
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use sha2::{Digest, Sha256};
146 use solana_keypair::Keypair;
147 use solana_message::{VersionedMessage, v0::Message as V0Message};
148 use solana_pubkey::Pubkey;
149 use solana_signer::Signer;
150 use solana_system_interface::instruction as system_instruction;
151 use solana_transaction::versioned::VersionedTransaction;
152 use surfpool_types::{SimnetCommand, TransactionConfirmationStatus, TransactionStatusEvent};
153
154 use super::*;
155 use crate::{
156 tests::helpers::TestSetup,
157 types::{SurfnetTransactionStatus, TransactionWithStatusMeta},
158 };
159
160 const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
161
162 fn build_v0_transaction(
163 payer: &Pubkey,
164 signers: &[&Keypair],
165 instructions: &[solana_instruction::Instruction],
166 recent_blockhash: &solana_hash::Hash,
167 ) -> VersionedTransaction {
168 let msg = VersionedMessage::V0(
169 V0Message::try_compile(payer, instructions, &[], *recent_blockhash).unwrap(),
170 );
171 VersionedTransaction::try_new(msg, signers).unwrap()
172 }
173
174 #[test]
175 fn test_send_bundle_empty_bundle_rejected() {
176 let setup = TestSetup::new(SurfpoolJitoRpc);
177 let result = setup.rpc.send_bundle(Some(setup.context), vec![], None);
178
179 assert!(result.is_err());
180 let err = result.unwrap_err();
181 assert!(
182 err.message.contains("Bundle cannot be empty"),
183 "Expected 'Bundle cannot be empty' error, got: {}",
184 err.message
185 );
186 }
187
188 #[test]
189 fn test_send_bundle_exceeds_max_size_rejected() {
190 let setup = TestSetup::new(SurfpoolJitoRpc);
191 let transactions = vec!["tx".to_string(); MAX_BUNDLE_SIZE + 1];
192 let result = setup
193 .rpc
194 .send_bundle(Some(setup.context), transactions, None);
195
196 assert!(result.is_err());
197 let err = result.unwrap_err();
198 assert!(
199 err.message.contains("exceeds maximum size"),
200 "Expected max size error, got: {}",
201 err.message
202 );
203 }
204
205 #[test]
206 fn test_send_bundle_no_context_returns_unhealthy() {
207 let setup = TestSetup::new(SurfpoolJitoRpc);
208 let result = setup
209 .rpc
210 .send_bundle(None, vec!["some_tx".to_string()], None);
211
212 assert!(result.is_err());
213 }
214
215 #[tokio::test(flavor = "multi_thread")]
216 async fn test_send_bundle_single_transaction() {
217 let payer = Keypair::new();
218 let recipient = Pubkey::new_unique();
219 let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
220 let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
221 let recent_blockhash = setup
222 .context
223 .svm_locker
224 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
225
226 let _ = setup
228 .context
229 .svm_locker
230 .0
231 .write()
232 .await
233 .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
234
235 let tx = build_v0_transaction(
236 &payer.pubkey(),
237 &[&payer],
238 &[system_instruction::transfer(
239 &payer.pubkey(),
240 &recipient,
241 LAMPORTS_PER_SOL,
242 )],
243 &recent_blockhash,
244 );
245 let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
246 let expected_sig = tx.signatures[0];
247
248 let setup_clone = setup.clone();
249 let handle = hiro_system_kit::thread_named("send_bundle")
250 .spawn(move || {
251 setup_clone
252 .rpc
253 .send_bundle(Some(setup_clone.context), vec![tx_encoded], None)
254 })
255 .unwrap();
256
257 loop {
259 match mempool_rx.recv() {
260 Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => {
261 let mut writer = setup.context.svm_locker.0.write().await;
262 let slot = writer.get_latest_absolute_slot();
263 writer.transactions_queued_for_confirmation.push_back((
264 tx.clone(),
265 status_tx.clone(),
266 None,
267 ));
268 let sig = tx.signatures[0];
269 let tx_with_status_meta = TransactionWithStatusMeta {
270 slot,
271 transaction: tx,
272 ..Default::default()
273 };
274 let mutated_accounts = std::collections::HashSet::new();
275 writer
276 .transactions
277 .store(
278 sig.to_string(),
279 SurfnetTransactionStatus::processed(
280 tx_with_status_meta,
281 mutated_accounts,
282 ),
283 )
284 .unwrap();
285 status_tx
286 .send(TransactionStatusEvent::Success(
287 TransactionConfirmationStatus::Confirmed,
288 ))
289 .unwrap();
290 break;
291 }
292 Ok(SimnetCommand::AirdropProcessed) => continue,
293 _ => panic!("failed to receive transaction from mempool"),
294 }
295 }
296
297 let result = handle.join().unwrap();
298 assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
299
300 let bundle_id = result.unwrap();
302 let mut hasher = Sha256::new();
303 hasher.update(expected_sig.to_string().as_bytes());
304 let expected_bundle_id = hex::encode(hasher.finalize());
305 assert_eq!(
306 bundle_id, expected_bundle_id,
307 "Bundle ID should match SHA-256 of signature"
308 );
309 }
310
311 #[tokio::test(flavor = "multi_thread")]
312 async fn test_send_bundle_multiple_transactions() {
313 let payer = Keypair::new();
314 let recipient1 = Pubkey::new_unique();
315 let recipient2 = Pubkey::new_unique();
316 let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
317 let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
318 let recent_blockhash = setup
319 .context
320 .svm_locker
321 .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
322
323 let _ = setup
325 .context
326 .svm_locker
327 .0
328 .write()
329 .await
330 .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
331
332 let tx1 = build_v0_transaction(
333 &payer.pubkey(),
334 &[&payer],
335 &[system_instruction::transfer(
336 &payer.pubkey(),
337 &recipient1,
338 LAMPORTS_PER_SOL,
339 )],
340 &recent_blockhash,
341 );
342 let tx2 = build_v0_transaction(
343 &payer.pubkey(),
344 &[&payer],
345 &[system_instruction::transfer(
346 &payer.pubkey(),
347 &recipient2,
348 LAMPORTS_PER_SOL,
349 )],
350 &recent_blockhash,
351 );
352
353 let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
354 let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
355 let expected_sig1 = tx1.signatures[0];
356 let expected_sig2 = tx2.signatures[0];
357
358 let setup_clone = setup.clone();
359 let handle = hiro_system_kit::thread_named("send_bundle")
360 .spawn(move || {
361 setup_clone.rpc.send_bundle(
362 Some(setup_clone.context),
363 vec![tx1_encoded, tx2_encoded],
364 None,
365 )
366 })
367 .unwrap();
368
369 let mut processed_count = 0;
371 while processed_count < 2 {
372 match mempool_rx.recv() {
373 Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => {
374 let mut writer = setup.context.svm_locker.0.write().await;
375 let slot = writer.get_latest_absolute_slot();
376 writer.transactions_queued_for_confirmation.push_back((
377 tx.clone(),
378 status_tx.clone(),
379 None,
380 ));
381 let sig = tx.signatures[0];
382 let tx_with_status_meta = TransactionWithStatusMeta {
383 slot,
384 transaction: tx,
385 ..Default::default()
386 };
387 let mutated_accounts = std::collections::HashSet::new();
388 writer
389 .transactions
390 .store(
391 sig.to_string(),
392 SurfnetTransactionStatus::processed(
393 tx_with_status_meta,
394 mutated_accounts,
395 ),
396 )
397 .unwrap();
398 status_tx
399 .send(TransactionStatusEvent::Success(
400 TransactionConfirmationStatus::Confirmed,
401 ))
402 .unwrap();
403 processed_count += 1;
404 }
405 Ok(SimnetCommand::AirdropProcessed) => continue,
406 _ => panic!("failed to receive transaction from mempool"),
407 }
408 }
409
410 let result = handle.join().unwrap();
411 assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
412
413 let bundle_id = result.unwrap();
415 let concatenated = format!("{},{}", expected_sig1, expected_sig2);
416 let mut hasher = Sha256::new();
417 hasher.update(concatenated.as_bytes());
418 let expected_bundle_id = hex::encode(hasher.finalize());
419 assert_eq!(
420 bundle_id, expected_bundle_id,
421 "Bundle ID should match SHA-256 of comma-separated signatures"
422 );
423 }
424}