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_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
25/// Maximum number of transactions allowed in a single bundle, matching Jito's limit.
26const MAX_BUNDLE_SIZE: usize = 5;
27
28/// Maximum number of bundle IDs accepted in a single `getBundleStatuses` request, matching
29/// Jito's documented limit. Larger batches are rejected with `invalid_params`.
30const MAX_BUNDLES_PER_QUERY: usize = 5;
31
32/// Jito-specific RPC methods for bundle submission
33#[rpc]
34pub trait Jito {
35    type Metadata;
36
37    /// Sends a bundle of transactions to be processed atomically.
38    ///
39    /// This RPC method accepts a bundle of transactions (Jito-compatible format) and processes them
40    /// one by one in order against an isolated sandbox VM. **The bundle is all-or-nothing**: if any
41    /// transaction in the bundle fails (simulation error, execution error, or verification error),
42    /// every other transaction's effects are discarded and the underlying VM is left byte-identical
43    /// to its pre-bundle state. No Geyser event, no Simnet event, and no WebSocket subscriber
44    /// notification is dispatched for a bundle that fails.
45    ///
46    /// On full success, the sandbox's state changes — account mutations, transaction storage
47    /// writes, token-account index updates, write-version increments, etc. — are atomically
48    /// committed onto the original VM under an exclusive writer guard, and Geyser/Simnet events
49    /// plus WebSocket subscriber notifications (account, program, signature, logs) are fired
50    /// onto the live event channels exactly as if each transaction had been submitted through
51    /// the regular `sendTransaction` RPC.
52    ///
53    /// ## Parameters
54    /// - `transactions`: An array of serialized transaction data (base64 or base58 encoded).
55    /// - `config`: Optional configuration for encoding format.
56    ///
57    /// ## Returns
58    /// - `BoxFuture<Result<String>>`: A future resolving to the bundle ID (SHA-256 hash of
59    ///   comma-separated signatures), or an error if any transaction in the bundle fails.
60    ///   Returning a future (rather than blocking) lets the JSON-RPC runtime drive the async
61    ///   sandbox execution without spawning a nested tokio runtime on an HTTP worker thread.
62    ///
63    /// ## Example Request (JSON-RPC)
64    /// ```json
65    /// {
66    ///   "jsonrpc": "2.0",
67    ///   "id": 1,
68    ///   "method": "sendBundle",
69    ///   "params": [
70    ///     ["base64EncodedTx1", "base64EncodedTx2"],
71    ///     { "encoding": "base64" }
72    ///   ]
73    /// }
74    /// ```
75    ///
76    /// ## Notes
77    /// - Bundles are limited to a maximum of 5 transactions, matching Jito's limit.
78    /// - Transactions are processed sequentially in the order provided against a single sandbox.
79    /// - Atomicity is guaranteed: on any failure the original VM is unaffected.
80    /// - The bundle ID is calculated as SHA-256 hash of comma-separated transaction signatures.
81    #[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    /// Retrieves Jito-shaped status summaries for one or more previously submitted bundles.
90    ///
91    /// Mirrors Jito's wire protocol: the first positional parameter is an **array** of bundle IDs
92    /// (max [`MAX_BUNDLES_PER_QUERY`]). For each requested id we resolve per-signature status via
93    /// the same path as `getSignatureStatuses` and return a single aggregate status object per
94    /// bundle, or `null` at that index when the bundle is unknown locally.
95    ///
96    /// ## Parameters
97    /// - `bundle_ids`: Array of bundle identifiers returned by `sendBundle`. May contain up to
98    ///   [`MAX_BUNDLES_PER_QUERY`] entries; the empty array is rejected with `invalid_params`.
99    ///
100    /// ## Returns
101    /// On success, the JSON-RPC `result` is the standard Solana contextualized response shape:
102    /// - `context.slot`: Context slot from the underlying status query (same idea as `getSignatureStatuses`).
103    /// - `value`: An array with exactly **one element per input bundle id**, at the same index:
104    ///   - `null` when the bundle id is not known locally (no stored signatures — the id may be
105    ///     valid elsewhere, we simply have nothing to report).
106    ///   - A `surfpool_types::JitoBundleStatus` object otherwise, with:
107    ///     - `bundle_id`: The requested bundle id (snake_case wire field).
108    ///     - `transactions`: Base-58 signatures in bundle submission order (from local `jito_bundles` storage).
109    ///     - `slot`: Slot from the first per-signature status entry (bundle txs share a landing slot), or `0` if none yet.
110    ///     - `confirmation_status`: From that same first entry (defaults to `processed` when absent).
111    ///     - `err`: `Ok` if no transaction error was observed on any status; otherwise the first `Err` encountered
112    ///       (JSON-serialized like other Solana `Result` values, e.g. `{"Ok": null}` or `{"Err": ...}`).
113    ///
114    /// ## Example Request (JSON-RPC)
115    /// ```json
116    /// {
117    ///   "jsonrpc": "2.0",
118    ///   "id": 1,
119    ///   "method": "getBundleStatuses",
120    ///   "params": [
121    ///     ["bundleIdHere", "anotherBundleId"]
122    ///   ]
123    /// }
124    /// ```
125    ///
126    /// ## Example Response (JSON-RPC)
127    /// ```json
128    /// {
129    ///   "jsonrpc": "2.0",
130    ///   "id": 1,
131    ///   "result": {
132    ///     "context": { "slot": 242806119 },
133    ///     "value": [
134    ///       {
135    ///         "bundle_id": "892b79ed49138bfb3aa5441f0df6e06ef34f9ee8f3976c15b323605bae0cf51d",
136    ///         "transactions": [
137    ///           "3bC2M9fiACSjkTXZDgeNAuQ4ScTsdKGwR42ytFdhUvikqTmBheUxfsR1fDVsM5ADCMMspuwGkdm1uKbU246x5aE3",
138    ///           "8t9hKYEYNbLvNqiSzP96S13XF1C2f1ro271Kdf7bkZ6EpjPLuDff1ywRy4gfaGSTubsM2FeYGDoT64ZwPm1cQUt"
139    ///         ],
140    ///         "slot": 242804011,
141    ///         "confirmation_status": "finalized",
142    ///         "err": { "Ok": null }
143    ///       },
144    ///       null
145    ///     ]
146    ///   }
147    /// }
148    /// ```
149    ///
150    /// ## Notes
151    /// - Bundles are stored locally as a mapping from `bundle_id` to a list of base-58 signatures.
152    /// - Unknown bundle ids appear as `null` **inside** the `value` array; the outer `result` is
153    ///   never `null` (Jito-style: per-index reporting).
154    /// - Per-signature status resolution uses the same logic as `getSignatureStatuses` (local store and optional remote datasource).
155    #[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    /// Simulates a bundle of transactions sequentially against an isolated sandbox VM,
163    /// without committing any of the resulting state changes onto the live VM.
164    ///
165    /// This is the read-only counterpart to [`Self::send_bundle`]. Where `send_bundle` is
166    /// **all-or-nothing** (every tx must succeed; on full success the sandbox is committed),
167    /// `simulate_bundle` is **all-tx-attempted-or-fail-fast**: each transaction is processed
168    /// in order against the sandbox, with subsequent transactions seeing the previous tx's
169    /// state mutations, but the moment one transaction errors the loop exits and remaining
170    /// transactions are not simulated. The sandbox is **always** discarded — successful or
171    /// not — so the live VM is byte-identical to its pre-call state regardless of outcome.
172    ///
173    /// Per-tx the response carries:
174    /// - `err`: `None` on success, the typed `TransactionError` on failure.
175    /// - `logs`: program logs the SVM emitted while executing the tx.
176    /// - `units_consumed`: compute units burned.
177    /// - `pre_execution_accounts` / `post_execution_accounts`: pre/post snapshot of the
178    ///   accounts whose pubkeys the caller listed in
179    ///   `RpcSimulateBundleConfig.{pre,post}_execution_accounts_configs[i]`. Returned in
180    ///   `UiAccountEncoding::Base64` (the only encoding `simulateBundle` supports — the
181    ///   request is rejected with `invalid_params` for any other encoding hint).
182    /// - `replacement_blockhash`: the bank's latest blockhash, but only when
183    ///   `replace_recent_blockhash` is true.
184    /// - `return_data`: the program return data, when present.
185    ///
186    /// The remaining fields on `RpcSimulateBundleTransactionResult` (`pre_token_balances`,
187    /// `post_token_balances`, `loaded_addresses`, `loaded_accounts_data_size`, `fee`,
188    /// `pre_balances`, `post_balances`) are returned as `None`. This matches Surfpool's
189    /// existing single-tx `simulateTransaction` behavior; populating them requires layout
190    /// decoding of SPL Token accounts that has not yet been done on either path. Tracked
191    /// for a follow-up PR.
192    ///
193    /// ## Parameters
194    /// - `rpc_bundle_request`: Wrapper struct carrying the array of base64-encoded
195    ///   transactions. Mirrors Jito's wire format.
196    /// - `config`: Optional. When omitted the bundle simulates with no pre/post account
197    ///   snapshots, sigverify on, and the originally-encoded blockhashes preserved. When
198    ///   `replace_recent_blockhash` is true, `skip_sig_verify` MUST also be true (a
199    ///   resigned blockhash invalidates any pre-existing signature) — the request is
200    ///   otherwise rejected with `invalid_params`.
201    ///
202    /// ## Returns
203    /// `RpcResponse<RpcSimulateBundleResult>` — the standard Solana contextualized response
204    /// shape. `value.summary` is `Succeeded` when every transaction in the bundle simulated
205    /// cleanly, otherwise `Failed { error, tx_signature }` where `error` carries the
206    /// typed `RpcBundleExecutionError::TransactionFailure(signature, message)` for the
207    /// first failing tx. `value.transaction_results[i]` corresponds to
208    /// `rpc_bundle_request.encoded_transactions[i]`; on early-exit failure the trailing
209    /// indices contain the empty/skipped tx result (err: None, logs: None, etc.) — they
210    /// were never simulated.
211    ///
212    /// ## Example Request (JSON-RPC)
213    /// ```json
214    /// {
215    ///   "jsonrpc": "2.0",
216    ///   "id": 1,
217    ///   "method": "simulateBundle",
218    ///   "params": [
219    ///     { "encodedTransactions": ["base64Tx1", "base64Tx2"] },
220    ///     {
221    ///       "preExecutionAccountsConfigs": [null, { "addresses": ["..."] }],
222    ///       "postExecutionAccountsConfigs": [null, { "addresses": ["..."] }],
223    ///       "skipSigVerify": true,
224    ///       "replaceRecentBlockhash": true
225    ///     }
226    ///   ]
227    /// }
228    /// ```
229    ///
230    /// ## Notes
231    /// - Bundles are limited to a maximum of [`MAX_BUNDLE_SIZE`] (5) transactions, matching
232    ///   `sendBundle` and Jito's documented limit.
233    /// - `pre_execution_accounts_configs` and `post_execution_accounts_configs`, when
234    ///   provided, MUST have the same length as the bundle.
235    /// - `simulation_bank` is accepted for API parity; Surfpool always simulates against
236    ///   the working SVM regardless of value.
237    /// - The sandbox's storage overlays, buffered Geyser/Simnet events, and cloned LiteSVM
238    ///   state are dropped at end-of-call. No notification fires on the live event channels;
239    ///   no signature/logs subscriber is woken; no chain state is touched.
240    #[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            // Decode all bundle transactions up front so we can run them against an isolated
282            // sandbox.
283            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            // -- Phase A: Sandbox execution -------------------------------------------------
311            // Take a brief read lock on the original VM to construct a sandbox whose storages
312            // are overlay-wrapped, whose subscription registries are empty (no live WS leak),
313            // and whose event channels buffer into receivers we hold here.
314            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                // Awaiting directly here lets the surrounding JSON-RPC runtime drive the
335                // future. We must NOT use `hiro_system_kit::nestable_block_on` because the
336                // HTTP worker thread is already inside a tokio runtime and `block_on` on the
337                // current handle panics with "Cannot start a runtime from within a runtime".
338                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                    // Dropping `sandbox_locker` discards all overlay state and the cloned
352                    // LiteSVM, so the original VM is byte-identical to its pre-bundle state.
353                    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                // `process_transaction` only returns after the sandbox has run the tx and
360                // dispatched a status event, so `try_recv`/`recv_timeout` will not actually
361                // park the worker for any meaningful time; the 2s timeout is a hard ceiling
362                // for an unexpectedly missed status.
363                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            // -- Phase B: Atomic commit -----------------------------------------------------
396            // All bundle transactions succeeded on the sandbox. Extract the sandbox SVM (the
397            // only remaining Arc reference is the local `sandbox_locker`), reassemble the
398            // BundleSandbox and call commit_sandbox under the original VM's writer lock.
399            let sandbox_svm = match Arc::try_unwrap(sandbox_locker.0) {
400                Ok(rwlock) => rwlock.into_inner(),
401                Err(_) => {
402                    // Should never happen: sandbox_locker was constructed locally and never
403                    // shared.
404                    return Err(Error::internal_error());
405                }
406            };
407            let reassembled = BundleSandbox {
408                svm: sandbox_svm,
409                geyser_rx,
410                simnet_rx,
411            };
412
413            // Use a discardable status channel for the bundle. The runloop will use it to
414            // attempt sending Confirmed/Finalized updates; nobody reads it so try_send fails
415            // silently.
416            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            // Calculate bundle ID by hashing comma-separated signatures (Jito-compatible)
429            // https://github.com/jito-foundation/jito-solana/blob/master/sdk/src/bundle/mod.rs#L21
430            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            // We need a single `context.slot` for the outer RpcResponse. The most accurate slot
473            // is the one returned by the underlying `get_signature_statuses` call; if all
474            // requested bundles are unknown locally we fall back to the locker's latest slot so
475            // the response shape still matches Solana's contextualized RPC contract.
476            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                // Bundle txs are processed sequentially in one go; they share the same landing
500                // slot and confirmation level, so we take slot/status from the first status
501                // entry only and aggregate `err` across all entries.
502                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            // Validate bundle size up front. Same cap as `send_bundle` (and Jito's
552            // documented limit of 5).
553            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            // Default config preserves Jito's wire-protocol shape: one None entry per
570            // tx for both pre/post snapshot configs.
571            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, // accepted for API parity, unused
583                skip_sig_verify,
584                replace_recent_blockhash,
585            } = config;
586
587            // Treat omitted/empty pre+post config vecs as "no snapshots
588            // requested for any tx" — equivalent to `vec![None; bundle_len]`.
589            // The wire types use `#[serde(default)]` so callers may send a
590            // partial config (just `skipSigVerify`, etc.) without specifying
591            // these arrays. Mismatched non-empty lengths below are rejected.
592            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            // Length of pre/post configs MUST match the bundle when provided.
604            // This matches Jito's contract — a mismatch would silently drop
605            // snapshots or panic on indexing.
606            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            // We only support base64 for the snapshotted accounts and for tx encoding.
615            // Base58 is too slow for byte-blob roundtrips at this size, and matching
616            // Jito's reference implementation here keeps the wire shape consistent.
617            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            // `replace_recent_blockhash` resigns the message; existing signatures no
632            // longer match. Reject the dangerous combination explicitly so callers
633            // notice rather than seeing a SignatureFailure mid-bundle.
634            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            // Match Jito's reference simulateBundle: only base64 is accepted.
641            // base58 is too slow at the byte-blob sizes bundle simulation uses,
642            // and accepting both creates client-side ambiguity over which one
643            // the server will use when none is specified.
644            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            // Decode every transaction up front — fail-fast on any decode error before
655            // we spend cycles cloning the SVM.
656            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                // Reject transactions without signatures up front. A versioned
672                // transaction is required to carry at least one signature
673                // (the fee-payer's); the validator rejects sig-less txs at
674                // ingest, so a bundle entry with empty `signatures` is not a
675                // valid Solana transaction. Rejecting here avoids having to
676                // synthesize a zero-byte placeholder Signature into the
677                // RpcBundleExecutionError::TransactionFailure(Signature, _)
678                // wire variant downstream — that would mislead clients
679                // keying off the sig inside `error`.
680                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            // Pre-resolve the pre/post pubkey lists per tx. Done once before the loop
690            // so a malformed pubkey errors out cleanly before we touch the sandbox.
691            let pre_pubkeys = parse_account_addresses(&pre_execution_accounts_configs)?;
692            let post_pubkeys = parse_account_addresses(&post_execution_accounts_configs)?;
693
694            // ---- Sandbox setup -------------------------------------------------------
695            // Same primitive `send_bundle` uses: clone the SVM with overlay-wrapped
696            // storages and emptied subscription registries, with event channels
697            // redirected into receivers we hold here. Any state mutations made during
698            // simulation are confined to the sandbox; on drop the overlay deltas, the
699            // cloned LiteSVM, and every buffered event are discarded.
700            //
701            // Unlike `send_bundle` we never call `commit_sandbox`. The sandbox is
702            // dropped at end-of-call regardless of bundle outcome — this is what
703            // distinguishes simulate from send.
704            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, // discarded on drop
710                simnet_rx: _simnet_rx, // discarded on drop
711            } = bundle_sandbox;
712            let sandbox_locker = SurfnetSvmLocker::new(sandbox_svm);
713
714            // ---- Optional blockhash replacement -------------------------------------
715            // Replace each tx's recent_blockhash with the sandbox VM's latest blockhash
716            // so historical/expired transactions can be replayed. Reproduces the
717            // RpcBlockhash payload Jito returns under `replacement_blockhash`.
718            let replacement_blockhash: Option<RpcBlockhash> = if replace_recent_blockhash {
719                // Pull both fields under a single reader lock and use the
720                // bank's actual block height (NOT the absolute slot) for
721                // last_valid_block_height — `RpcBlockhash` documents this as
722                // a block height, and clients rely on the distinction.
723                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            // ---- Sequential simulation loop -----------------------------------------
743            // Initialize per-tx results to the empty/skipped shape. We overwrite
744            // entries as we simulate; on early-exit failure the trailing entries stay
745            // empty (matching Jito's "skipped txs after first failure" semantics).
746            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            // Move owned txs into the loop — `into_iter()` so we can pass each
752            // `tx` by value into `fetch_all_tx_accounts_then_process_tx_returning_profile_res`
753            // without cloning. `decoded_txs` is not used after this point.
754            for (idx, tx) in decoded_txs.into_iter().enumerate() {
755                // We rejected sig-less txs at decode time, so signatures[0]
756                // always exists here.
757                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                // Snapshot pre-state for the requested pubkeys BEFORE running the
762                // tx. When the caller did not ask for a snapshot for this tx, the
763                // pubkey list is empty and we surface the field as None — matches
764                // Jito's wire shape (null vs []).
765                let pre_accounts = snapshot_accounts(&sandbox_locker, &pre_pubkeys[idx]).await?;
766
767                // Pre-pass sigverify when the caller asked us to verify
768                // signatures. We do it ourselves so a failure surfaces a typed
769                // SignatureFailure (or AlreadyProcessed) directly — the inner
770                // locker call's sigverify path erases the typed err into a
771                // SurfpoolError on return, which would force us to string-match
772                // to recover the variant.
773                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                // Per-iteration status channel: do_propagate=true causes the
810                // locker to emit TransactionStatusEvent::SimulationFailure /
811                // ExecutionFailure into our channel on tx error, carrying the
812                // typed TransactionError. The sandbox's signature/logs subscriber
813                // registries were emptied by clone_for_bundle_sandbox, so the
814                // notify_*_subscribers calls upstream of the send fire to nobody.
815                // The receiver is dropped at end-of-iteration so events never
816                // accumulate across the bundle.
817                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                        // sigverify=false: we already verified above when the
825                        // caller asked for it. Avoids double-work on the hot path.
826                        false,
827                        true, // do_propagate -> status_rx receives typed errors
828                    )
829                    .await;
830
831                // Always attempt the post-snapshot — the caller asked for it and a
832                // failed tx may have partially mutated state. If the snapshot itself
833                // errors (sandbox poisoned, etc.) we surface that to the caller.
834                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                        // Drain the status channel non-blockingly to recover the
851                        // typed TransactionError when the tx errored. The locker
852                        // emits exactly one event per tx; absence (try_recv ->
853                        // Empty) means the tx succeeded.
854                        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                                // VerificationFailure → SignatureFailure: matches
863                                // svm::sigverify and the single-tx simulate path
864                                // in full.rs. Unreachable in our flow (we run the
865                                // inner call with sigverify=false after pre-passing
866                                // it ourselves, so the locker's sigverify gate
867                                // can't fire), but kept for exhaustiveness against
868                                // future locker changes.
869                                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                            // Tx errored. Use the typed err recovered from the
877                            // status channel; if for some reason no typed event
878                            // arrived (race or future code change), fall back to
879                            // None — clients should rely on summary's
880                            // TransactionFailure(signature, message) anyway, which
881                            // carries the upstream string regardless.
882                            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                        // Success path.
900                        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                        // Pre-processing failure (account loading, ALT resolution,
911                        // AccountLoadedTwice). Sigverify was caught + early-exited
912                        // above so it can't reach here. The locker doesn't push
913                        // to the status channel on this path — process_transaction's
914                        // catch-all wrapper does, but we bypass it. try_recv is
915                        // expected to be empty; the match is defensive against
916                        // future emit-before-bubble changes in the locker.
917                        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            // Sandbox dropped here. Overlay storages, cloned LiteSVM, and buffered
951            // event channels are all reclaimed; the live VM is byte-identical to its
952            // pre-call state.
953            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
970// ---------------------------------------------------------------------------
971// simulate_bundle helpers
972//
973// Kept private to this module since they're only useful in the bundle-
974// simulation path. If/when single-tx simulateTransaction grows similar
975// pre/post-snapshot capabilities the parsing/snapshot/encoding helpers can
976// move up into a shared module.
977// ---------------------------------------------------------------------------
978
979/// Parse the `addresses` lists from a `Vec<Option<RpcSimulateTransactionAccountsConfig>>`
980/// into a parallel `Vec<Vec<Pubkey>>`. A `None` entry yields an empty inner vec —
981/// the caller treats "no addresses requested" identically to "explicitly empty".
982fn 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
1004/// Snapshot the requested pubkeys from the sandbox locker, encoding each as
1005/// a `UiAccount` with base64-encoded data. Missing accounts are encoded as
1006/// the canonical "empty" shape (zero lamports, system-program owner) so
1007/// indexes still align with the requested pubkey list — the caller can tell
1008/// "missing" apart from "empty" by inspecting the returned `lamports`.
1009async 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                // The account does not exist in the sandbox (or has zero lamports
1033                // and no data). We surface a canonical "missing" placeholder:
1034                // zero-lamport, system-program-owned, empty data. Encoding owner
1035                // as system_program::id() (rather than relying on
1036                // Account::default(), which produces an all-zero owner pubkey)
1037                // matches what `getAccountInfo` returns for never-created system
1038                // accounts and avoids surprising downstream consumers that key
1039                // on the owner field. simulateTransaction's `accounts` array
1040                // does the same.
1041                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
1066/// Initial value for each per-tx result slot. Pre-fill the bundle results
1067/// with this so trailing entries stay self-consistent when the bundle exits
1068/// early on failure.
1069fn 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
1090/// Construct a per-tx bundle simulation result from the fields we actually
1091/// populate. The remaining fields — `return_data`, `fee`, lamport balances,
1092/// token balances, loaded addresses, `loaded_accounts_data_size` — are
1093/// uniformly None in this implementation. Closing that gap requires piping
1094/// richer metadata through `ProfileResult`; tracked for a follow-up PR. See
1095/// the doc comment on `RpcSimulateBundleTransactionResult` in
1096/// `surfpool-types::jito_bundles` for the canonical list. Centralizing the
1097/// construction here keeps the success / typed-error / internal-error
1098/// branches in lockstep when new fields land.
1099fn 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        // Airdrop to payer
1288        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        // Verify bundle ID is SHA-256 of the signature
1317        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        // Verify recipient balance reflects the committed bundle
1327        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        // Airdrop to payer
1353        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        // Both recipient balances should reflect committed bundle
1399        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        // Verify bundle ID is SHA-256 of comma-separated signatures
1419        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        // Use mempool-backed setup so we can assert that a sandbox failure does NOT enqueue any
1436        // ProcessTransaction commands
1437        let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded();
1438        let setup = TestSetup::new_with_mempool(SurfpoolJitoRpc, mempool_tx);
1439
1440        // Drain any ProcessTransaction commands so `sendTransaction` cannot block this test even
1441        // if Phase 2 is accidentally reached. We track whether anything was sent.
1442        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                            // Minimal bookkeeping (mirrors other bundle tests) + unblock the RPC.
1458                            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        // Airdrop to payer so tx1 can fund the recipient.
1496        let _ = setup
1497            .context
1498            .svm_locker
1499            .0
1500            .write()
1501            .await
1502            .airdrop(&payer.pubkey(), 5 * LAMPORTS_PER_SOL);
1503        // tx1: payer -> recipient (funds recipient so it can pay fees for tx2)
1504        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        // tx2 depends on tx1 having executed (recipient needs funds), but must still fail.
1516        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); // this should be fine, since the recp. kp was new, it's not in the svm state
1562
1563        assert_eq!(
1564            recp_bal, 0,
1565            "expected jito bundle to not take effect after bundle failure"
1566        );
1567
1568        // If sandbox failure happens as expected, Phase 2 should never run.
1569        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        // Build a tx that should fail during `simulateTransaction` because the payer
1581        // has no lamports (no explicit airdrop in this test).
1582        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        // Airdrop to payer so tx can succeed in our manual processing
1633        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        // Build expected signatures locally (what we expect to be persisted under bundle_id)
1654        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        // sendBundle stores bundle signatures directly in `jito_bundles`; poll until visible.
1667        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        // Multi-tx bundle must still aggregate into exactly one JitoBundleStatus, with the
1807        // signatures preserved in submission order.
1808        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        // -- Ok case: field names must be snake_case (Jito wire-compatible) and err must
1844        // serialize as {"Ok": null}. --
1845        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        // err must serialize as {"Ok": null} for a successful bundle.
1876        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        // -- Err case: err must serialize as {"Err": ...} carrying the inner TransactionError. --
1890        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        // Round-trip: deserializing must yield the same struct.
1905        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        // Submit one real bundle, then call getBundleStatuses with a batch containing the real
1913        // id plus an unknown id. The response must preserve order and include `null` at the
1914        // unknown index, matching Jito's wire contract.
1915        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        // Order: [unknown, known] so we also verify positional null-vs-Some mapping isn't
1951        // accidentally first-only.
1952        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    // -----------------------------------------------------------------------
1977    // simulate_bundle tests
1978    // -----------------------------------------------------------------------
1979
1980    /// Build a base64-encoded SOL transfer ready to feed into simulate_bundle.
1981    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            // Only 1 pre-config but the bundle has 2 txs — must reject.
2099            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        // Fund the payer on the LIVE VM so the simulation has something to spend
2137        // from when its sandbox clones from the live SVM.
2138        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        // Pre-balance check on the live VM.
2149        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        // Bundle summary: success.
2191        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        // Critically: live VM is byte-identical to its pre-call state.
2219        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        // Fund payer with only 0.5 SOL — first tx asks for 1 SOL transfer, so
2244        // the second tx (also 1 SOL) is guaranteed to never run. Actually the
2245        // first tx itself will fail because payer has insufficient funds — the
2246        // test asserts on fail-fast semantics: tx_results[0] errored, summary
2247        // = Failed, tx_results[1] left in skipped (empty) state.
2248        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    /// Pins the Jito wire-format contract for null pre/post account configs.
2295    /// Reviewer @greptile-apps caught that we were emitting `Some([])` where
2296    /// the reference returns `None` — clients distinguishing "not requested"
2297    /// from "requested but empty" would see a false positive.
2298    #[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        // Caller does NOT request pre/post snapshots for this tx (None entry).
2319        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    /// Pins the Jito wire-format contract for explicit-empty pubkey lists.
2354    /// Distinguishing this from the None case lets clients tell "I asked for
2355    /// nothing" apart from "I didn't ask".
2356    #[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        // Caller DID request snapshots, but with an empty pubkey list.
2377        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    /// Pins the rejection of non-base64 tx encodings — Jito's reference does
2422    /// the same. Without this guard a base58 caller would silently get a
2423    /// confusing "unsupported encoding" deeper in the stack.
2424    #[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    /// Pins typed `TransactionError` propagation for execution failures.
2456    /// A bundle whose first tx tries to spend more lamports than the wallet
2457    /// holds must surface the typed `InstructionError(0, Custom(1))` (or the
2458    /// SVM's equivalent typed err) — NOT a generic `SanitizeFailure`.
2459    /// Reviewer @greptile-apps + @copilot both flagged the SanitizeFailure
2460    /// catch-all as actively misleading.
2461    #[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        // Fund the wallet with FAR less than the transfer amount asks for —
2472        // SVM execution will reject the tx with a typed error.
2473        let _ = setup
2474            .context
2475            .svm_locker
2476            .0
2477            .write()
2478            .await
2479            .airdrop(&payer.pubkey(), 1_000); // 0.000001 SOL — not enough for fees, let alone the transfer
2480
2481        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        // Bundle summary should be Failed.
2496        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        // The typed error should NOT be SanitizeFailure (the previous
2511        // implementation's catch-all). For an insufficient-funds-during-
2512        // execution path the SVM typically reports either an
2513        // InstructionError or a typed transaction error like
2514        // InsufficientFundsForRent — anything BUT SanitizeFailure is the
2515        // win we are pinning.
2516        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    /// Pins the SignatureFailure mapping for sigverify failures. A bundle with
2527    /// a tx whose signature has been corrupted (post-sign mutation) and
2528    /// `skip_sig_verify=false` must surface `TransactionError::SignatureFailure`
2529    /// in the typed err — NOT `SanitizeFailure` (the previous catch-all that
2530    /// reviewer @greptile-apps flagged) and NOT a generic untyped err.
2531    /// `SanitizeFailure` semantically means structurally malformed; the right
2532    /// variant here is the same one `svm::sigverify` and the single-tx
2533    /// simulate path already use.
2534    #[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        // Build a valid transfer tx, then corrupt the first signature byte —
2545        // the surrounding message stays well-formed (passes sanitize) but the
2546        // signature no longer verifies.
2547        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                    // Sigverify ON — we want to exercise the verification path.
2578                    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    /// Pins the up-front rejection of sig-less transactions. Reviewer
2605    /// @copilot flagged that `unwrap_or_default()` on a None signature
2606    /// would inject an all-zero `Signature` into the
2607    /// `RpcBundleExecutionError::TransactionFailure(Signature, _)` wire
2608    /// shape — clients keying off the sig inside `error` would be misled.
2609    /// Rejecting the tx at decode time avoids the synthesis entirely.
2610    #[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        // Build a versioned tx with NO signatures. Note: Solana's signing
2619        // helpers always inject the fee-payer sig, so we go through the
2620        // raw constructor and leave signatures empty.
2621        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![], // explicit empty
2638            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    /// Pins the partial-config behavior. Reviewer @copilot flagged that
2668    /// `RpcSimulateBundleConfig` previously required both
2669    /// `pre_execution_accounts_configs` and `post_execution_accounts_configs`
2670    /// to be present in JSON, contradicting the docstring's implication
2671    /// that callers may send a partial config. Now both fields are
2672    /// `#[serde(default)]` and an empty/omitted vec is treated as
2673    /// `vec![None; bundle_len]`.
2674    #[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        // Send 1 SOL — well above rent-exempt minimum so recipient creation
2693        // doesn't fail with InsufficientFundsForRent.
2694        let tx_b64 = make_transfer_b64(&payer, &recipient, LAMPORTS_PER_SOL, &recent_blockhash);
2695
2696        // Partial config: only skip_sig_verify is set, both pre/post account
2697        // config arrays are omitted (default = empty vec). Server must
2698        // expand them to vec![None; bundle_len] and accept the request.
2699        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}