Skip to main content

surfpool_core/rpc/
jito.rs

1use std::sync::Arc;
2
3use jsonrpc_core::{BoxFuture, Error, Result};
4use jsonrpc_derive::rpc;
5use sha2::{Digest, Sha256};
6use solana_client::{rpc_config::RpcSendTransactionConfig, rpc_custom_error::RpcCustomError};
7use solana_rpc_client_api::response::{Response as RpcResponse, RpcResponseContext};
8use solana_signature::Signature;
9use solana_transaction::versioned::VersionedTransaction;
10use solana_transaction_status::{TransactionConfirmationStatus, UiTransactionEncoding};
11use surfpool_types::{JitoBundleStatus, TransactionStatusEvent};
12
13use super::{RunloopContext, utils::decode_and_deserialize};
14use crate::{
15    rpc::full::SurfpoolFullRpc,
16    surfnet::{locker::SurfnetSvmLocker, svm::BundleSandbox},
17};
18
19/// Maximum number of transactions allowed in a single bundle, matching Jito's limit.
20const MAX_BUNDLE_SIZE: usize = 5;
21
22/// Maximum number of bundle IDs accepted in a single `getBundleStatuses` request, matching
23/// Jito's documented limit. Larger batches are rejected with `invalid_params`.
24const MAX_BUNDLES_PER_QUERY: usize = 5;
25
26/// Jito-specific RPC methods for bundle submission
27#[rpc]
28pub trait Jito {
29    type Metadata;
30
31    /// Sends a bundle of transactions to be processed atomically.
32    ///
33    /// This RPC method accepts a bundle of transactions (Jito-compatible format) and processes them
34    /// one by one in order against an isolated sandbox VM. **The bundle is all-or-nothing**: if any
35    /// transaction in the bundle fails (simulation error, execution error, or verification error),
36    /// every other transaction's effects are discarded and the underlying VM is left byte-identical
37    /// to its pre-bundle state. No Geyser event, no Simnet event, and no WebSocket subscriber
38    /// notification is dispatched for a bundle that fails.
39    ///
40    /// On full success, the sandbox's state changes — account mutations, transaction storage
41    /// writes, token-account index updates, write-version increments, etc. — are atomically
42    /// committed onto the original VM under an exclusive writer guard, and Geyser/Simnet events
43    /// plus WebSocket subscriber notifications (account, program, signature, logs) are fired
44    /// onto the live event channels exactly as if each transaction had been submitted through
45    /// the regular `sendTransaction` RPC.
46    ///
47    /// ## Parameters
48    /// - `transactions`: An array of serialized transaction data (base64 or base58 encoded).
49    /// - `config`: Optional configuration for encoding format.
50    ///
51    /// ## Returns
52    /// - `BoxFuture<Result<String>>`: A future resolving to the bundle ID (SHA-256 hash of
53    ///   comma-separated signatures), or an error if any transaction in the bundle fails.
54    ///   Returning a future (rather than blocking) lets the JSON-RPC runtime drive the async
55    ///   sandbox execution without spawning a nested tokio runtime on an HTTP worker thread.
56    ///
57    /// ## Example Request (JSON-RPC)
58    /// ```json
59    /// {
60    ///   "jsonrpc": "2.0",
61    ///   "id": 1,
62    ///   "method": "sendBundle",
63    ///   "params": [
64    ///     ["base64EncodedTx1", "base64EncodedTx2"],
65    ///     { "encoding": "base64" }
66    ///   ]
67    /// }
68    /// ```
69    ///
70    /// ## Notes
71    /// - Bundles are limited to a maximum of 5 transactions, matching Jito's limit.
72    /// - Transactions are processed sequentially in the order provided against a single sandbox.
73    /// - Atomicity is guaranteed: on any failure the original VM is unaffected.
74    /// - The bundle ID is calculated as SHA-256 hash of comma-separated transaction signatures.
75    #[rpc(meta, name = "sendBundle")]
76    fn send_bundle(
77        &self,
78        meta: Self::Metadata,
79        transactions: Vec<String>,
80        config: Option<RpcSendTransactionConfig>,
81    ) -> BoxFuture<Result<String>>;
82
83    /// Retrieves Jito-shaped status summaries for one or more previously submitted bundles.
84    ///
85    /// Mirrors Jito's wire protocol: the first positional parameter is an **array** of bundle IDs
86    /// (max [`MAX_BUNDLES_PER_QUERY`]). For each requested id we resolve per-signature status via
87    /// the same path as `getSignatureStatuses` and return a single aggregate status object per
88    /// bundle, or `null` at that index when the bundle is unknown locally.
89    ///
90    /// ## Parameters
91    /// - `bundle_ids`: Array of bundle identifiers returned by `sendBundle`. May contain up to
92    ///   [`MAX_BUNDLES_PER_QUERY`] entries; the empty array is rejected with `invalid_params`.
93    ///
94    /// ## Returns
95    /// On success, the JSON-RPC `result` is the standard Solana contextualized response shape:
96    /// - `context.slot`: Context slot from the underlying status query (same idea as `getSignatureStatuses`).
97    /// - `value`: An array with exactly **one element per input bundle id**, at the same index:
98    ///   - `null` when the bundle id is not known locally (no stored signatures — the id may be
99    ///     valid elsewhere, we simply have nothing to report).
100    ///   - A `surfpool_types::JitoBundleStatus` object otherwise, with:
101    ///     - `bundle_id`: The requested bundle id (snake_case wire field).
102    ///     - `transactions`: Base-58 signatures in bundle submission order (from local `jito_bundles` storage).
103    ///     - `slot`: Slot from the first per-signature status entry (bundle txs share a landing slot), or `0` if none yet.
104    ///     - `confirmation_status`: From that same first entry (defaults to `processed` when absent).
105    ///     - `err`: `Ok` if no transaction error was observed on any status; otherwise the first `Err` encountered
106    ///       (JSON-serialized like other Solana `Result` values, e.g. `{"Ok": null}` or `{"Err": ...}`).
107    ///
108    /// ## Example Request (JSON-RPC)
109    /// ```json
110    /// {
111    ///   "jsonrpc": "2.0",
112    ///   "id": 1,
113    ///   "method": "getBundleStatuses",
114    ///   "params": [
115    ///     ["bundleIdHere", "anotherBundleId"]
116    ///   ]
117    /// }
118    /// ```
119    ///
120    /// ## Example Response (JSON-RPC)
121    /// ```json
122    /// {
123    ///   "jsonrpc": "2.0",
124    ///   "id": 1,
125    ///   "result": {
126    ///     "context": { "slot": 242806119 },
127    ///     "value": [
128    ///       {
129    ///         "bundle_id": "892b79ed49138bfb3aa5441f0df6e06ef34f9ee8f3976c15b323605bae0cf51d",
130    ///         "transactions": [
131    ///           "3bC2M9fiACSjkTXZDgeNAuQ4ScTsdKGwR42ytFdhUvikqTmBheUxfsR1fDVsM5ADCMMspuwGkdm1uKbU246x5aE3",
132    ///           "8t9hKYEYNbLvNqiSzP96S13XF1C2f1ro271Kdf7bkZ6EpjPLuDff1ywRy4gfaGSTubsM2FeYGDoT64ZwPm1cQUt"
133    ///         ],
134    ///         "slot": 242804011,
135    ///         "confirmation_status": "finalized",
136    ///         "err": { "Ok": null }
137    ///       },
138    ///       null
139    ///     ]
140    ///   }
141    /// }
142    /// ```
143    ///
144    /// ## Notes
145    /// - Bundles are stored locally as a mapping from `bundle_id` to a list of base-58 signatures.
146    /// - Unknown bundle ids appear as `null` **inside** the `value` array; the outer `result` is
147    ///   never `null` (Jito-style: per-index reporting).
148    /// - Per-signature status resolution uses the same logic as `getSignatureStatuses` (local store and optional remote datasource).
149    #[rpc(meta, name = "getBundleStatuses")]
150    fn get_bundle_statuses(
151        &self,
152        meta: Self::Metadata,
153        bundle_ids: Vec<String>,
154    ) -> BoxFuture<Result<RpcResponse<Vec<Option<JitoBundleStatus>>>>>;
155}
156
157#[derive(Clone)]
158pub struct SurfpoolJitoRpc;
159
160impl Jito for SurfpoolJitoRpc {
161    type Metadata = Option<RunloopContext>;
162
163    fn send_bundle(
164        &self,
165        meta: Self::Metadata,
166        transactions: Vec<String>,
167        config: Option<RpcSendTransactionConfig>,
168    ) -> BoxFuture<Result<String>> {
169        Box::pin(async move {
170            if transactions.is_empty() {
171                return Err(Error::invalid_params("Bundle cannot be empty"));
172            }
173
174            if transactions.len() > MAX_BUNDLE_SIZE {
175                return Err(Error::invalid_params(format!(
176                    "Bundle exceeds maximum size of {MAX_BUNDLE_SIZE} transactions"
177                )));
178            }
179
180            let Some(ctx) = meta else {
181                return Err(RpcCustomError::NodeUnhealthy {
182                    num_slots_behind: None,
183                }
184                .into());
185            };
186
187            let base_config = config.unwrap_or_default();
188
189            // Decode all bundle transactions up front so we can run them against an isolated
190            // sandbox.
191            let tx_encoding = base_config
192                .encoding
193                .unwrap_or(UiTransactionEncoding::Base58);
194            let binary_encoding = tx_encoding.into_binary_encoding().ok_or_else(|| {
195                Error::invalid_params(format!(
196                    "unsupported encoding: {tx_encoding}. Supported encodings: base58, base64"
197                ))
198            })?;
199
200            let mut decoded_txs: Vec<VersionedTransaction> = Vec::with_capacity(transactions.len());
201            for (idx, tx_data) in transactions.iter().enumerate() {
202                let (_, tx) = decode_and_deserialize::<VersionedTransaction>(
203                    tx_data.clone(),
204                    binary_encoding,
205                )
206                .map_err(|e| Error {
207                    code: e.code,
208                    message: format!(
209                        "Failed to decode bundle transaction {}: {}",
210                        idx + 1,
211                        e.message
212                    ),
213                    data: e.data,
214                })?;
215                decoded_txs.push(tx);
216            }
217
218            // -- Phase A: Sandbox execution -------------------------------------------------
219            // Take a brief read lock on the original VM to construct a sandbox whose storages
220            // are overlay-wrapped, whose subscription registries are empty (no live WS leak),
221            // and whose event channels buffer into receivers we hold here.
222            let bundle_sandbox = ctx
223                .svm_locker
224                .with_svm_reader(|svm_reader| svm_reader.clone_for_bundle_sandbox());
225
226            let BundleSandbox {
227                svm: sandbox_svm,
228                geyser_rx,
229                simnet_rx,
230            } = bundle_sandbox;
231
232            let sandbox_locker = SurfnetSvmLocker::new(sandbox_svm);
233
234            let remote_ctx = &None;
235            let skip_preflight = true;
236            let sigverify = true;
237
238            let mut bundle_signatures: Vec<Signature> = Vec::with_capacity(decoded_txs.len());
239            for (idx, tx) in decoded_txs.iter().enumerate() {
240                let (status_tx, status_rx) = crossbeam_channel::bounded(1);
241
242                // Awaiting directly here lets the surrounding JSON-RPC runtime drive the
243                // future. We must NOT use `hiro_system_kit::nestable_block_on` because the
244                // HTTP worker thread is already inside a tokio runtime and `block_on` on the
245                // current handle panics with "Cannot start a runtime from within a runtime".
246                let process_res = sandbox_locker
247                    .process_transaction(
248                        remote_ctx,
249                        tx.clone(),
250                        status_tx,
251                        skip_preflight,
252                        sigverify,
253                    )
254                    .await;
255
256                bundle_signatures.push(tx.signatures[0]);
257
258                if let Err(e) = process_res {
259                    // Dropping `sandbox_locker` discards all overlay state and the cloned
260                    // LiteSVM, so the original VM is byte-identical to its pre-bundle state.
261                    return Err(Error::invalid_params(format!(
262                        "Jito bundle couldn't be executed, failed to process transaction {}: {e}",
263                        idx + 1
264                    )));
265                }
266
267                // `process_transaction` only returns after the sandbox has run the tx and
268                // dispatched a status event, so `try_recv`/`recv_timeout` will not actually
269                // park the worker for any meaningful time; the 2s timeout is a hard ceiling
270                // for an unexpectedly missed status.
271                match status_rx.recv_timeout(std::time::Duration::from_secs(2)) {
272                    Ok(TransactionStatusEvent::Success(_)) => {}
273                    Ok(TransactionStatusEvent::SimulationFailure(other)) => {
274                        return Err(Error::invalid_params(format!(
275                            "Jito bundle couldn't be executed: simulation failed for transaction {}: {:?}",
276                            idx + 1,
277                            other
278                        )));
279                    }
280                    Ok(TransactionStatusEvent::ExecutionFailure(other)) => {
281                        return Err(Error::invalid_params(format!(
282                            "Jito bundle couldn't be executed: Execution failed for transaction {}: {:?}",
283                            idx + 1,
284                            other
285                        )));
286                    }
287                    Ok(TransactionStatusEvent::VerificationFailure(ver_fail_err)) => {
288                        return Err(Error::invalid_params(format!(
289                            "Jito bundle couldn't be executed: Verification failed for transaction {}: {:?}",
290                            idx + 1,
291                            ver_fail_err
292                        )));
293                    }
294                    Err(_) => {
295                        return Err(RpcCustomError::NodeUnhealthy {
296                            num_slots_behind: None,
297                        }
298                        .into());
299                    }
300                }
301            }
302
303            // -- Phase B: Atomic commit -----------------------------------------------------
304            // All bundle transactions succeeded on the sandbox. Extract the sandbox SVM (the
305            // only remaining Arc reference is the local `sandbox_locker`), reassemble the
306            // BundleSandbox and call commit_sandbox under the original VM's writer lock.
307            let sandbox_svm = match Arc::try_unwrap(sandbox_locker.0) {
308                Ok(rwlock) => rwlock.into_inner(),
309                Err(_) => {
310                    // Should never happen: sandbox_locker was constructed locally and never
311                    // shared.
312                    return Err(Error::internal_error());
313                }
314            };
315            let reassembled = BundleSandbox {
316                svm: sandbox_svm,
317                geyser_rx,
318                simnet_rx,
319            };
320
321            // Use a discardable status channel for the bundle. The runloop will use it to
322            // attempt sending Confirmed/Finalized updates; nobody reads it so try_send fails
323            // silently.
324            let (bundle_status_tx, _bundle_status_rx) = crossbeam_channel::unbounded();
325
326            ctx.svm_locker
327                .with_svm_writer(move |original| {
328                    original.commit_sandbox(reassembled, bundle_status_tx)
329                })
330                .map_err(|e| {
331                    Error::invalid_params(format!(
332                        "Jito bundle commit failed after successful sandbox execution: {e}"
333                    ))
334                })?;
335
336            // Calculate bundle ID by hashing comma-separated signatures (Jito-compatible)
337            // https://github.com/jito-foundation/jito-solana/blob/master/sdk/src/bundle/mod.rs#L21
338            let concatenated_signatures = bundle_signatures
339                .iter()
340                .map(|sig| sig.to_string())
341                .collect::<Vec<_>>()
342                .join(",");
343            let mut hasher = Sha256::new();
344            hasher.update(concatenated_signatures.as_bytes());
345            let bundle_id = hex::encode(hasher.finalize());
346
347            ctx.svm_locker.store_bundle(
348                bundle_id.clone(),
349                bundle_signatures
350                    .iter()
351                    .map(|sig| sig.to_string())
352                    .collect(),
353            )?;
354            Ok(bundle_id)
355        })
356    }
357
358    fn get_bundle_statuses(
359        &self,
360        meta: Self::Metadata,
361        bundle_ids: Vec<String>,
362    ) -> BoxFuture<Result<RpcResponse<Vec<Option<JitoBundleStatus>>>>> {
363        Box::pin(async move {
364            if bundle_ids.is_empty() {
365                return Err(Error::invalid_params("bundle_ids cannot be empty"));
366            }
367            if bundle_ids.len() > MAX_BUNDLES_PER_QUERY {
368                return Err(Error::invalid_params(format!(
369                    "bundle_ids exceeds maximum of {MAX_BUNDLES_PER_QUERY} per request"
370                )));
371            }
372
373            let Some(ctx) = &meta else {
374                return Err(RpcCustomError::NodeUnhealthy {
375                    num_slots_behind: None,
376                }
377                .into());
378            };
379
380            // We need a single `context.slot` for the outer RpcResponse. The most accurate slot
381            // is the one returned by the underlying `get_signature_statuses` call; if all
382            // requested bundles are unknown locally we fall back to the locker's latest slot so
383            // the response shape still matches Solana's contextualized RPC contract.
384            let mut last_context: Option<RpcResponseContext> = None;
385            let mut value: Vec<Option<JitoBundleStatus>> = Vec::with_capacity(bundle_ids.len());
386
387            for bundle_id in bundle_ids {
388                let Some(signatures) = ctx.svm_locker.get_bundle(&bundle_id) else {
389                    value.push(None);
390                    continue;
391                };
392                if signatures.is_empty() {
393                    value.push(None);
394                    continue;
395                }
396
397                let statuses = super::full::Full::get_signature_statuses(
398                    &SurfpoolFullRpc,
399                    meta.clone(),
400                    signatures.clone(),
401                    None,
402                )
403                .await?;
404
405                last_context = Some(statuses.context.clone());
406
407                // Bundle txs are processed sequentially in one go; they share the same landing
408                // slot and confirmation level, so we take slot/status from the first status
409                // entry only and aggregate `err` across all entries.
410                let (slot, confirmation_status, first_err) = {
411                    let mut iter = statuses.value.iter().flatten();
412
413                    let (slot, confirmation_status, head_err) = match iter.next() {
414                        Some(first) => (
415                            first.slot,
416                            first.confirmation_status.clone(),
417                            first.err.clone(),
418                        ),
419                        None => (0, None, None),
420                    };
421
422                    let first_err = head_err.or_else(|| iter.find_map(|s| s.err.clone()));
423                    (slot, confirmation_status, first_err)
424                };
425
426                let confirmation_status =
427                    confirmation_status.unwrap_or(TransactionConfirmationStatus::Processed);
428
429                value.push(Some(JitoBundleStatus {
430                    bundle_id,
431                    transactions: signatures,
432                    slot,
433                    confirmation_status,
434                    err: match first_err {
435                        Some(e) => Err(e),
436                        None => Ok(()),
437                    },
438                }));
439            }
440
441            let context = last_context.unwrap_or_else(|| {
442                let slot = ctx
443                    .svm_locker
444                    .with_svm_reader(|svm| svm.get_latest_absolute_slot());
445                RpcResponseContext::new(slot)
446            });
447
448            Ok(RpcResponse { context, value })
449        })
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use std::{
456        sync::{
457            Arc,
458            atomic::{AtomicBool, AtomicUsize, Ordering},
459        },
460        time::Duration,
461    };
462
463    use sha2::{Digest, Sha256};
464    use solana_keypair::Keypair;
465    use solana_message::{VersionedMessage, v0::Message as V0Message};
466    use solana_pubkey::Pubkey;
467    use solana_signer::Signer;
468    use solana_system_interface::instruction as system_instruction;
469    use solana_transaction::versioned::VersionedTransaction;
470    use solana_transaction_status::TransactionConfirmationStatus as SolanaTxConfirmationStatus;
471    use surfpool_types::{SimnetCommand, TransactionConfirmationStatus, TransactionStatusEvent};
472
473    use super::*;
474    use crate::{
475        tests::helpers::TestSetup,
476        types::{SurfnetTransactionStatus, TransactionWithStatusMeta},
477    };
478
479    const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
480
481    fn build_v0_transaction(
482        payer: &Pubkey,
483        signers: &[&Keypair],
484        instructions: &[solana_instruction::Instruction],
485        recent_blockhash: &solana_hash::Hash,
486    ) -> VersionedTransaction {
487        let msg = VersionedMessage::V0(
488            V0Message::try_compile(payer, instructions, &[], *recent_blockhash).unwrap(),
489        );
490        VersionedTransaction::try_new(msg, signers).unwrap()
491    }
492
493    #[tokio::test(flavor = "multi_thread")]
494    async fn test_send_bundle_empty_bundle_rejected() {
495        let setup = TestSetup::new(SurfpoolJitoRpc);
496        let result = setup
497            .rpc
498            .send_bundle(Some(setup.context.clone()), vec![], None)
499            .await;
500
501        assert!(result.is_err());
502        let err = result.unwrap_err();
503        assert!(
504            err.message.contains("Bundle cannot be empty"),
505            "Expected 'Bundle cannot be empty' error, got: {}",
506            err.message
507        );
508    }
509
510    #[tokio::test(flavor = "multi_thread")]
511    async fn test_send_bundle_exceeds_max_size_rejected() {
512        let setup = TestSetup::new(SurfpoolJitoRpc);
513        let transactions = vec!["tx".to_string(); MAX_BUNDLE_SIZE + 1];
514        let result = setup
515            .rpc
516            .send_bundle(Some(setup.context.clone()), transactions, None)
517            .await;
518
519        assert!(result.is_err());
520        let err = result.unwrap_err();
521        assert!(
522            err.message.contains("exceeds maximum size"),
523            "Expected max size error, got: {}",
524            err.message
525        );
526    }
527
528    #[tokio::test(flavor = "multi_thread")]
529    async fn test_send_bundle_no_context_returns_unhealthy() {
530        let setup = TestSetup::new(SurfpoolJitoRpc);
531        let result = setup
532            .rpc
533            .send_bundle(None, vec!["some_tx".to_string()], None)
534            .await;
535
536        assert!(result.is_err());
537    }
538
539    #[tokio::test(flavor = "multi_thread")]
540    async fn test_get_bundle_statuses_unknown_bundle_returns_null_entry() {
541        let setup = TestSetup::new(SurfpoolJitoRpc);
542        let missing_id = "a".repeat(64);
543        let response = setup
544            .rpc
545            .get_bundle_statuses(Some(setup.context), vec![missing_id])
546            .await
547            .expect("getBundleStatuses should not return a JSON-RPC error");
548        assert_eq!(
549            response.value.len(),
550            1,
551            "value array must have one entry per requested bundle id"
552        );
553        assert!(
554            response.value[0].is_none(),
555            "unknown bundle_id should appear as a null entry inside `value`, not as an outer null"
556        );
557    }
558
559    #[tokio::test(flavor = "multi_thread")]
560    async fn test_get_bundle_statuses_empty_input_rejected() {
561        let setup = TestSetup::new(SurfpoolJitoRpc);
562        let result = setup
563            .rpc
564            .get_bundle_statuses(Some(setup.context), vec![])
565            .await;
566        assert!(result.is_err(), "empty bundle_ids should be rejected");
567        let err = result.unwrap_err();
568        assert!(
569            err.message.contains("cannot be empty"),
570            "Expected empty-input error, got: {}",
571            err.message
572        );
573    }
574
575    #[tokio::test(flavor = "multi_thread")]
576    async fn test_get_bundle_statuses_exceeds_max_per_query_rejected() {
577        let setup = TestSetup::new(SurfpoolJitoRpc);
578        let too_many = vec!["a".repeat(64); MAX_BUNDLES_PER_QUERY + 1];
579        let result = setup
580            .rpc
581            .get_bundle_statuses(Some(setup.context), too_many)
582            .await;
583        assert!(
584            result.is_err(),
585            "exceeding MAX_BUNDLES_PER_QUERY should error"
586        );
587        let err = result.unwrap_err();
588        assert!(
589            err.message.contains("exceeds maximum"),
590            "Expected max-batch error, got: {}",
591            err.message
592        );
593    }
594
595    #[tokio::test(flavor = "multi_thread")]
596    async fn test_get_bundle_statuses_no_context_returns_unhealthy() {
597        let setup = TestSetup::new(SurfpoolJitoRpc);
598        let result = setup
599            .rpc
600            .get_bundle_statuses(None, vec!["a".repeat(64)])
601            .await;
602        assert!(result.is_err());
603    }
604
605    #[tokio::test(flavor = "multi_thread")]
606    async fn test_send_bundle_single_transaction() {
607        let payer = Keypair::new();
608        let recipient = Pubkey::new_unique();
609        let setup = TestSetup::new(SurfpoolJitoRpc);
610        let recent_blockhash = setup
611            .context
612            .svm_locker
613            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
614
615        // Airdrop to payer
616        let _ = setup
617            .context
618            .svm_locker
619            .0
620            .write()
621            .await
622            .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
623
624        let tx = build_v0_transaction(
625            &payer.pubkey(),
626            &[&payer],
627            &[system_instruction::transfer(
628                &payer.pubkey(),
629                &recipient,
630                LAMPORTS_PER_SOL,
631            )],
632            &recent_blockhash,
633        );
634        let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
635        let expected_sig = tx.signatures[0];
636
637        let result = setup
638            .rpc
639            .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
640            .await;
641
642        assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
643
644        // Verify bundle ID is SHA-256 of the signature
645        let bundle_id = result.unwrap();
646        let mut hasher = Sha256::new();
647        hasher.update(expected_sig.to_string().as_bytes());
648        let expected_bundle_id = hex::encode(hasher.finalize());
649        assert_eq!(
650            bundle_id, expected_bundle_id,
651            "Bundle ID should match SHA-256 of signature"
652        );
653
654        // Verify recipient balance reflects the committed bundle
655        let recipient_lamports = setup
656            .context
657            .svm_locker
658            .with_svm_reader(|svm| svm.get_account(&recipient))
659            .ok()
660            .flatten()
661            .map(|a| a.lamports)
662            .unwrap_or(0);
663        assert_eq!(
664            recipient_lamports, LAMPORTS_PER_SOL,
665            "Bundle commit should have applied lamport transfer to recipient"
666        );
667    }
668
669    #[tokio::test(flavor = "multi_thread")]
670    async fn test_send_bundle_multiple_transactions() {
671        let payer = Keypair::new();
672        let recipient1 = Pubkey::new_unique();
673        let recipient2 = Pubkey::new_unique();
674        let setup = TestSetup::new(SurfpoolJitoRpc);
675        let recent_blockhash = setup
676            .context
677            .svm_locker
678            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
679
680        // Airdrop to payer
681        let _ = setup
682            .context
683            .svm_locker
684            .0
685            .write()
686            .await
687            .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
688
689        let tx1 = build_v0_transaction(
690            &payer.pubkey(),
691            &[&payer],
692            &[system_instruction::transfer(
693                &payer.pubkey(),
694                &recipient1,
695                LAMPORTS_PER_SOL,
696            )],
697            &recent_blockhash,
698        );
699        let tx2 = build_v0_transaction(
700            &payer.pubkey(),
701            &[&payer],
702            &[system_instruction::transfer(
703                &payer.pubkey(),
704                &recipient2,
705                LAMPORTS_PER_SOL,
706            )],
707            &recent_blockhash,
708        );
709
710        let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
711        let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
712        let expected_sig1 = tx1.signatures[0];
713        let expected_sig2 = tx2.signatures[0];
714
715        let result = setup
716            .rpc
717            .send_bundle(
718                Some(setup.context.clone()),
719                vec![tx1_encoded, tx2_encoded],
720                None,
721            )
722            .await;
723
724        assert!(result.is_ok(), "Bundle should succeed: {:?}", result);
725
726        // Both recipient balances should reflect committed bundle
727        let recipient1_lamports = setup
728            .context
729            .svm_locker
730            .with_svm_reader(|svm| svm.get_account(&recipient1))
731            .ok()
732            .flatten()
733            .map(|a| a.lamports)
734            .unwrap_or(0);
735        let recipient2_lamports = setup
736            .context
737            .svm_locker
738            .with_svm_reader(|svm| svm.get_account(&recipient2))
739            .ok()
740            .flatten()
741            .map(|a| a.lamports)
742            .unwrap_or(0);
743        assert_eq!(recipient1_lamports, LAMPORTS_PER_SOL);
744        assert_eq!(recipient2_lamports, LAMPORTS_PER_SOL);
745
746        // Verify bundle ID is SHA-256 of comma-separated signatures
747        let bundle_id = result.unwrap();
748        let concatenated = format!("{},{}", expected_sig1, expected_sig2);
749        let mut hasher = Sha256::new();
750        hasher.update(concatenated.as_bytes());
751        let expected_bundle_id = hex::encode(hasher.finalize());
752        assert_eq!(
753            bundle_id, expected_bundle_id,
754            "Bundle ID should match SHA-256 of comma-separated signatures"
755        );
756    }
757
758    #[tokio::test(flavor = "multi_thread")]
759    async fn test_send_bundle_dependent_transaction_failure_aborts_entire_bundle() {
760        let payer = Keypair::new();
761        let recipient = Keypair::new();
762
763        // Use mempool-backed setup so we can assert that a sandbox failure does NOT enqueue any
764        // ProcessTransaction commands
765        let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
766        let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
767
768        // Drain any ProcessTransaction commands so `sendTransaction` cannot block this test even
769        // if Phase 2 is accidentally reached. We track whether anything was sent.
770        let observed_process_tx = Arc::new(AtomicUsize::new(0));
771        let stop_drain = Arc::new(AtomicBool::new(false));
772        let observed_process_tx_clone = observed_process_tx.clone();
773        let stop_drain_clone = stop_drain.clone();
774        let svm_locker_clone = setup.context.svm_locker.clone();
775        let drain_handle = hiro_system_kit::thread_named("mempool_drain_dependent_bundle")
776            .spawn(move || {
777                while !stop_drain_clone.load(Ordering::SeqCst) {
778                    let Ok(cmd) = mempool_rx.recv_timeout(Duration::from_millis(200)) else {
779                        continue;
780                    };
781                    match cmd {
782                        SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _) => {
783                            observed_process_tx_clone.fetch_add(1, Ordering::SeqCst);
784
785                            // Minimal bookkeeping (mirrors other bundle tests) + unblock the RPC.
786                            let sig = tx.signatures[0];
787                            let mut writer = svm_locker_clone.0.blocking_write();
788                            let slot = writer.get_latest_absolute_slot();
789                            writer.transactions_queued_for_confirmation.push_back((
790                                tx.clone(),
791                                status_tx.clone(),
792                                None,
793                            ));
794                            let tx_with_status_meta = TransactionWithStatusMeta {
795                                slot,
796                                transaction: tx,
797                                ..Default::default()
798                            };
799                            let mutated_accounts = std::collections::HashSet::new();
800                            let _ = writer.transactions.store(
801                                sig.to_string(),
802                                SurfnetTransactionStatus::processed(
803                                    tx_with_status_meta,
804                                    mutated_accounts,
805                                ),
806                            );
807
808                            let _ = status_tx.send(TransactionStatusEvent::Success(
809                                TransactionConfirmationStatus::Confirmed,
810                            ));
811                        }
812                        _ => continue,
813                    }
814                }
815            })
816            .unwrap();
817
818        let recent_blockhash = setup
819            .context
820            .svm_locker
821            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
822
823        // Airdrop to payer so tx1 can fund the recipient.
824        let _ = setup
825            .context
826            .svm_locker
827            .0
828            .write()
829            .await
830            .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
831        // tx1: payer -> recipient (funds recipient so it can pay fees for tx2)
832        let tx1 = build_v0_transaction(
833            &payer.pubkey(),
834            &[&payer],
835            &[system_instruction::transfer(
836                &payer.pubkey(),
837                &recipient.pubkey(),
838                LAMPORTS_PER_SOL,
839            )],
840            &recent_blockhash,
841        );
842
843        // tx2 depends on tx1 having executed (recipient needs funds), but must still fail.
844        let tx2 = build_v0_transaction(
845            &recipient.pubkey(),
846            &[&recipient],
847            &[system_instruction::transfer(
848                &recipient.pubkey(),
849                &payer.pubkey(),
850                2 * LAMPORTS_PER_SOL,
851            )],
852            &recent_blockhash,
853        );
854
855        let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
856        let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
857
858        let result = setup
859            .rpc
860            .send_bundle(
861                Some(setup.context.clone()),
862                vec![tx1_encoded, tx2_encoded],
863                None,
864            )
865            .await;
866
867        assert!(
868            result.is_err(),
869            "Bundle should fail if any sandbox transaction fails"
870        );
871        let err = result.unwrap_err();
872        assert!(
873            err.message.contains("Jito bundle couldn't be executed"),
874            "Expected sandbox failure for tx2, got: {}",
875            err.message
876        );
877
878        stop_drain.store(true, Ordering::SeqCst);
879        let _ = drain_handle.join();
880
881        let recp_pubkey = recipient.pubkey();
882        let recp_bal = setup
883            .context
884            .svm_locker
885            .with_svm_reader(|svm| svm.get_account(&recp_pubkey))
886            .ok()
887            .flatten()
888            .map(|a| a.lamports)
889            .unwrap_or(0); // this should be fine, since the recp. kp was new, it's not in the svm state
890
891        assert_eq!(
892            recp_bal, 0,
893            "expected jito bundle to not take effect after bundle failure"
894        );
895
896        // If sandbox failure happens as expected, Phase 2 should never run.
897        assert_eq!(
898            observed_process_tx.load(Ordering::SeqCst),
899            0,
900            "Expected zero mempool ProcessTransaction commands; sandbox failure should prevent Phase 2"
901        );
902    }
903
904    #[tokio::test(flavor = "multi_thread")]
905    async fn test_send_bundle_simulation_failure_returns_not_atomic_error() {
906        let setup = TestSetup::new(SurfpoolJitoRpc);
907
908        // Build a tx that should fail during `simulateTransaction` because the payer
909        // has no lamports (no explicit airdrop in this test).
910        let payer = Keypair::new();
911        let recipient = Pubkey::new_unique();
912        let recent_blockhash = setup
913            .context
914            .svm_locker
915            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
916
917        let tx = build_v0_transaction(
918            &payer.pubkey(),
919            &[&payer],
920            &[system_instruction::transfer(
921                &payer.pubkey(),
922                &recipient,
923                LAMPORTS_PER_SOL,
924            )],
925            &recent_blockhash,
926        );
927        let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
928
929        let result = setup
930            .rpc
931            .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
932            .await;
933        assert!(result.is_err());
934        let err = result.unwrap_err();
935
936        assert!(
937            err.message.contains("Jito bundle couldn't be executed"),
938            "Expected not-atomic error, got: {}",
939            err.message
940        );
941        assert!(
942            err.message.contains("Jito bundle couldn't be executed:"),
943            "Expected simulation-failure error for transaction 1, got: {}",
944            err.message
945        );
946    }
947
948    #[tokio::test(flavor = "multi_thread")]
949    async fn test_send_bundle_persists_bundle_signatures() {
950        let payer = Keypair::new();
951        let recipient = Pubkey::new_unique();
952        let (mempool_tx, _) = crossbeam_channel::unbounded();
953        let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
954
955        let recent_blockhash = setup
956            .context
957            .svm_locker
958            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
959
960        // Airdrop to payer so tx can succeed in our manual processing
961        let _ = setup
962            .context
963            .svm_locker
964            .0
965            .write()
966            .await
967            .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
968
969        let tx = build_v0_transaction(
970            &payer.pubkey(),
971            &[&payer],
972            &[system_instruction::transfer(
973                &payer.pubkey(),
974                &recipient,
975                LAMPORTS_PER_SOL,
976            )],
977            &recent_blockhash,
978        );
979        let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
980
981        // Build expected signatures locally (what we expect to be persisted under bundle_id)
982        let expected_sigs = vec![tx.signatures[0].to_string()];
983
984        let setup_clone = setup.clone();
985        let send_bundle_result = setup_clone
986            .rpc
987            .send_bundle(Some(setup_clone.context), vec![tx_encoded], None)
988            .await;
989
990        assert!(send_bundle_result.is_ok(), "Expected send_bundle to pass");
991
992        let bundle_id = send_bundle_result.unwrap();
993
994        // sendBundle stores bundle signatures directly in `jito_bundles`; poll until visible.
995        let started = std::time::Instant::now();
996        let timeout = std::time::Duration::from_secs(2);
997        let persisted = loop {
998            match setup.context.svm_locker.get_bundle(&bundle_id) {
999                Some(sigs) if !sigs.is_empty() => break sigs,
1000                _ if started.elapsed() > timeout => {
1001                    panic!("timed out waiting for bundle to be persisted: {bundle_id}");
1002                }
1003                _ => std::thread::sleep(std::time::Duration::from_millis(10)),
1004            }
1005        };
1006        assert!(
1007            !persisted.is_empty(),
1008            "svm_locker.get_bundle(bundle_id) should not be empty"
1009        );
1010        assert_eq!(
1011            persisted, expected_sigs,
1012            "Persisted bundle signatures should match locally built signatures"
1013        );
1014
1015        let started = std::time::Instant::now();
1016        let timeout = std::time::Duration::from_secs(2);
1017        let (bundle, context_slot) = loop {
1018            let response = setup
1019                .rpc
1020                .get_bundle_statuses(Some(setup.context.clone()), vec![bundle_id.clone()])
1021                .await
1022                .expect("getBundleStatuses should succeed");
1023
1024            assert_eq!(
1025                response.value.len(),
1026                1,
1027                "getBundleStatuses should return a single status entry per requested id"
1028            );
1029
1030            let context_slot = response.context.slot;
1031            let bundle = response
1032                .value
1033                .into_iter()
1034                .next()
1035                .unwrap()
1036                .expect("bundle should exist locally after sendBundle");
1037            if bundle.slot != 0 {
1038                break (bundle, context_slot);
1039            }
1040
1041            if started.elapsed() > timeout {
1042                break (bundle, context_slot);
1043            }
1044
1045            std::thread::sleep(std::time::Duration::from_millis(10));
1046        };
1047
1048        assert!(
1049            context_slot >= bundle.slot,
1050            "response.context.slot ({}) should be >= bundle.slot ({}); \
1051             getBundleStatuses must surface the same context slot as the \
1052             underlying getSignatureStatuses call",
1053            context_slot,
1054            bundle.slot,
1055        );
1056
1057        assert_eq!(bundle.bundle_id, bundle_id, "bundle_id should match");
1058        assert_eq!(
1059            bundle.transactions, expected_sigs,
1060            "transactions should match bundle signatures"
1061        );
1062        assert!(
1063            matches!(
1064                bundle.confirmation_status,
1065                SolanaTxConfirmationStatus::Processed
1066                    | SolanaTxConfirmationStatus::Confirmed
1067                    | SolanaTxConfirmationStatus::Finalized
1068            ),
1069            "confirmation_status should be a valid Solana status"
1070        );
1071        assert!(bundle.err.is_ok(), "err should be Ok for successful bundle");
1072    }
1073
1074    #[tokio::test(flavor = "multi_thread")]
1075    async fn test_get_bundle_statuses_multi_transaction_bundle() {
1076        let payer = Keypair::new();
1077        let recipient1 = Pubkey::new_unique();
1078        let recipient2 = Pubkey::new_unique();
1079        let setup = TestSetup::new(SurfpoolJitoRpc);
1080        let recent_blockhash = setup
1081            .context
1082            .svm_locker
1083            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1084
1085        let _ = setup
1086            .context
1087            .svm_locker
1088            .0
1089            .write()
1090            .await
1091            .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
1092
1093        let tx1 = build_v0_transaction(
1094            &payer.pubkey(),
1095            &[&payer],
1096            &[system_instruction::transfer(
1097                &payer.pubkey(),
1098                &recipient1,
1099                LAMPORTS_PER_SOL,
1100            )],
1101            &recent_blockhash,
1102        );
1103        let tx2 = build_v0_transaction(
1104            &payer.pubkey(),
1105            &[&payer],
1106            &[system_instruction::transfer(
1107                &payer.pubkey(),
1108                &recipient2,
1109                LAMPORTS_PER_SOL,
1110            )],
1111            &recent_blockhash,
1112        );
1113
1114        let tx1_encoded = bs58::encode(bincode::serialize(&tx1).unwrap()).into_string();
1115        let tx2_encoded = bs58::encode(bincode::serialize(&tx2).unwrap()).into_string();
1116        let expected_sigs = vec![tx1.signatures[0].to_string(), tx2.signatures[0].to_string()];
1117
1118        let bundle_id = setup
1119            .rpc
1120            .send_bundle(
1121                Some(setup.context.clone()),
1122                vec![tx1_encoded, tx2_encoded],
1123                None,
1124            )
1125            .await
1126            .expect("sendBundle should succeed for a valid 2-tx bundle");
1127
1128        let response = setup
1129            .rpc
1130            .get_bundle_statuses(Some(setup.context.clone()), vec![bundle_id.clone()])
1131            .await
1132            .expect("getBundleStatuses should succeed");
1133
1134        // Multi-tx bundle must still aggregate into exactly one JitoBundleStatus, with the
1135        // signatures preserved in submission order.
1136        assert_eq!(
1137            response.value.len(),
1138            1,
1139            "value array must have one entry per requested bundle id"
1140        );
1141        let bundle = response
1142            .value
1143            .into_iter()
1144            .next()
1145            .unwrap()
1146            .expect("bundle should exist locally after sendBundle");
1147        assert_eq!(bundle.bundle_id, bundle_id);
1148        assert_eq!(
1149            bundle.transactions, expected_sigs,
1150            "transactions must preserve submission order across all txs in the bundle"
1151        );
1152        assert!(
1153            bundle.err.is_ok(),
1154            "successful multi-tx bundle should report Ok"
1155        );
1156        assert!(
1157            matches!(
1158                bundle.confirmation_status,
1159                SolanaTxConfirmationStatus::Processed
1160                    | SolanaTxConfirmationStatus::Confirmed
1161                    | SolanaTxConfirmationStatus::Finalized
1162            ),
1163            "confirmation_status should be a valid Solana status"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_jito_bundle_status_json_shape() {
1169        use solana_transaction_error::TransactionError;
1170
1171        // -- Ok case: field names must be snake_case (Jito wire-compatible) and err must
1172        // serialize as {"Ok": null}. --
1173        let ok_status = JitoBundleStatus {
1174            bundle_id: "abc123".to_string(),
1175            transactions: vec!["sig1".to_string(), "sig2".to_string()],
1176            slot: 42,
1177            confirmation_status: SolanaTxConfirmationStatus::Finalized,
1178            err: Ok(()),
1179        };
1180        let json = serde_json::to_value(&ok_status).expect("JitoBundleStatus should serialize");
1181
1182        assert!(
1183            json.get("bundle_id").is_some(),
1184            "expected snake_case `bundle_id` field, got: {json}"
1185        );
1186        assert!(json.get("transactions").is_some());
1187        assert!(json.get("slot").is_some());
1188        assert!(
1189            json.get("confirmationStatus").is_some(),
1190            "expected snake_case `confirmationStatus` field, got: {json}"
1191        );
1192        assert!(json.get("err").is_some());
1193
1194        assert!(
1195            json.get("bundleId").is_none(),
1196            "camelCase `bundleId` should not be serialized (Jito uses snake_case on the wire)"
1197        );
1198        assert!(
1199            json.get("confirmation_status").is_none(),
1200            "camelCase `confirmation_status` should not be serialized"
1201        );
1202
1203        // err must serialize as {"Ok": null} for a successful bundle.
1204        assert_eq!(
1205            json.get("err"),
1206            Some(&serde_json::json!({ "Ok": null })),
1207            "Ok variant of err should serialize as {{\"Ok\": null}}"
1208        );
1209        assert_eq!(json.get("bundle_id").unwrap().as_str(), Some("abc123"));
1210        assert_eq!(json.get("slot").unwrap().as_u64(), Some(42));
1211        assert_eq!(
1212            json.get("confirmationStatus").unwrap().as_str(),
1213            Some("finalized"),
1214            "confirmationStatus should serialize as a lowercase string"
1215        );
1216
1217        // -- Err case: err must serialize as {"Err": ...} carrying the inner TransactionError. --
1218        let err_status = JitoBundleStatus {
1219            bundle_id: "abc123".to_string(),
1220            transactions: vec!["sig1".to_string()],
1221            slot: 7,
1222            confirmation_status: SolanaTxConfirmationStatus::Processed,
1223            err: Err(TransactionError::AccountNotFound),
1224        };
1225        let err_json = serde_json::to_value(&err_status).expect("err variant should serialize");
1226        let err_field = err_json.get("err").expect("err field should be present");
1227        assert!(
1228            err_field.get("Err").is_some(),
1229            "Err variant of err should serialize as {{\"Err\": ...}}, got: {err_field}"
1230        );
1231
1232        // Round-trip: deserializing must yield the same struct.
1233        let round_tripped: JitoBundleStatus =
1234            serde_json::from_value(json).expect("JitoBundleStatus should round-trip");
1235        assert_eq!(round_tripped, ok_status);
1236    }
1237
1238    #[tokio::test(flavor = "multi_thread")]
1239    async fn test_get_bundle_statuses_batched_known_and_unknown() {
1240        // Submit one real bundle, then call getBundleStatuses with a batch containing the real
1241        // id plus an unknown id. The response must preserve order and include `null` at the
1242        // unknown index, matching Jito's wire contract.
1243        let payer = Keypair::new();
1244        let recipient = Pubkey::new_unique();
1245        let setup = TestSetup::new(SurfpoolJitoRpc);
1246        let recent_blockhash = setup
1247            .context
1248            .svm_locker
1249            .with_svm_reader(|svm_reader| svm_reader.latest_blockhash());
1250
1251        let _ = setup
1252            .context
1253            .svm_locker
1254            .0
1255            .write()
1256            .await
1257            .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL);
1258
1259        let tx = build_v0_transaction(
1260            &payer.pubkey(),
1261            &[&payer],
1262            &[system_instruction::transfer(
1263                &payer.pubkey(),
1264                &recipient,
1265                LAMPORTS_PER_SOL,
1266            )],
1267            &recent_blockhash,
1268        );
1269        let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string();
1270
1271        let known_id = setup
1272            .rpc
1273            .send_bundle(Some(setup.context.clone()), vec![tx_encoded], None)
1274            .await
1275            .expect("sendBundle should succeed");
1276        let unknown_id = "f".repeat(64);
1277
1278        // Order: [unknown, known] so we also verify positional null-vs-Some mapping isn't
1279        // accidentally first-only.
1280        let response = setup
1281            .rpc
1282            .get_bundle_statuses(
1283                Some(setup.context.clone()),
1284                vec![unknown_id.clone(), known_id.clone()],
1285            )
1286            .await
1287            .expect("getBundleStatuses should succeed");
1288
1289        assert_eq!(
1290            response.value.len(),
1291            2,
1292            "value must have exactly one entry per requested bundle id"
1293        );
1294        assert!(
1295            response.value[0].is_none(),
1296            "index 0 (unknown id) should be null"
1297        );
1298        let known = response.value[1]
1299            .as_ref()
1300            .expect("index 1 (known id) should be Some(JitoBundleStatus)");
1301        assert_eq!(known.bundle_id, known_id);
1302    }
1303}