Skip to main content

surfpool_core/rpc/
jito.rs

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
14/// Maximum number of transactions allowed in a single bundle, matching Jito's limit.
15const MAX_BUNDLE_SIZE: usize = 5;
16
17/// Jito-specific RPC methods for bundle submission
18#[rpc]
19pub trait Jito {
20    type Metadata;
21
22    /// Sends a bundle of transactions to be processed sequentially.
23    ///
24    /// This RPC method accepts a bundle of transactions (Jito-compatible format) and processes them
25    /// one by one in order. All transactions in the bundle must succeed for the bundle to be accepted.
26    ///
27    /// ## Parameters
28    /// - `transactions`: An array of serialized transaction data (base64 or base58 encoded).
29    /// - `config`: Optional configuration for encoding format.
30    ///
31    /// ## Returns
32    /// - `Result<String>`: A bundle ID (SHA-256 hash of comma-separated signatures).
33    ///
34    /// ## Example Request (JSON-RPC)
35    /// ```json
36    /// {
37    ///   "jsonrpc": "2.0",
38    ///   "id": 1,
39    ///   "method": "sendBundle",
40    ///   "params": [
41    ///     ["base64EncodedTx1", "base64EncodedTx2"],
42    ///     { "encoding": "base64" }
43    ///   ]
44    /// }
45    /// ```
46    ///
47    /// ## Notes
48    /// - Bundles are limited to a maximum of 5 transactions, matching Jito's limit
49    /// - Transactions are processed sequentially in the order provided
50    /// - Each transaction must complete successfully before the next one starts
51    /// - If any transaction fails, an error is returned — however, earlier transactions in the
52    ///   bundle that already succeeded are NOT rolled back (not atomic)
53    /// - TODO(#594): implement atomic all-or-nothing bundle execution
54    /// - The bundle ID is calculated as SHA-256 hash of comma-separated transaction signatures
55    #[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        // Process each transaction in the bundle sequentially using Full RPC
98        // Force skip_preflight to match Jito Block Engine behavior (no simulation on sendBundle)
99        // NOTE: this is not atomic — earlier transactions are NOT rolled back if a later one fails.
100        // TODO(#594): implement atomic all-or-nothing bundle execution
101        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            // Delegate to Full RPC's sendTransaction method
110            match full_rpc.send_transaction(meta.clone(), tx_data.clone(), bundle_config) {
111                Ok(signature_str) => {
112                    // Parse the signature to collect for bundle ID calculation
113                    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                    // Add bundle transaction index to error message
120                    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        // Calculate bundle ID by hashing comma-separated signatures (Jito-compatible)
130        // https://github.com/jito-foundation/jito-solana/blob/master/sdk/src/bundle/mod.rs#L21
131        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        // Airdrop to payer
227        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        // Process the transaction from mempool
258        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        // Verify bundle ID is SHA-256 of the signature
301        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        // Airdrop to payer
324        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        // Process both transactions from mempool
370        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        // Verify bundle ID is SHA-256 of comma-separated signatures
414        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}