From 7074f6bdac57dab2955ac8cc5dab5a57ccc2cbc4 Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Thu, 25 Jun 2026 22:23:46 -0400 Subject: [PATCH 1/3] feat(core): execute transactions asynchronously to mirror a Solana node sendTransaction now validates and (unless skipPreflight) simulates the transaction up front, returning an error on failure. On success it dispatches the transaction to the execution engine and immediately returns the signature instead of blocking until execution completes, matching a real Solana node; clients fetch the result by signature once it has executed. To keep an accepted transaction from spuriously failing if its blockhash ages out during the asynchronous delay, blockhash recency is validated once at admission (ValidatedRecentBlockhashAtAdmission) and trusted at execution, while durable-nonce transactions still validate at execution (ValidateAtExecution). The admission-time checks honor the global --skip-signature-verification and --skip-blockhash-check escape hatches; skip_signature_verification is plumbed onto SurfnetSvm alongside skip_blockhash_check. The same change is propagated to the Jito bundle path, the block-production runloop, and the SDK. --- crates/cli/src/cli/mod.rs | 1 + crates/core/src/rpc/full.rs | 289 ++++++++++++++++++++---------- crates/core/src/rpc/jito.rs | 6 +- crates/core/src/runloops/mod.rs | 13 +- crates/core/src/surfnet/locker.rs | 123 ++++++++++++- crates/core/src/surfnet/svm.rs | 154 ++++++++++++---- crates/sdk/src/surfnet.rs | 1 + crates/types/src/types.rs | 24 ++- 8 files changed, 456 insertions(+), 155 deletions(-) diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 53d9fe987..2e993039c 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -616,6 +616,7 @@ impl StartSimnet { Some(self.observability.log_bytes_limit) }, skip_blockhash_check: self.svm.skip_blockhash_check, + skip_signature_verification: self.svm.skip_signature_verification, } } diff --git a/crates/core/src/rpc/full.rs b/crates/core/src/rpc/full.rs index 3f4f79af2..3376cb97f 100644 --- a/crates/core/src/rpc/full.rs +++ b/crates/core/src/rpc/full.rs @@ -33,7 +33,9 @@ use solana_transaction_status::{ EncodedConfirmedTransactionWithStatusMeta, TransactionBinaryEncoding, TransactionConfirmationStatus, TransactionStatus, UiConfirmedBlock, }; -use surfpool_types::{SimnetCommand, TransactionStatusEvent}; +use surfpool_types::{ + ProcessTransactionRequest, SimnetCommand, TransactionBlockhashValidationMode, +}; use super::{ RunloopContext, State, SurfnetRpcContext, @@ -48,9 +50,10 @@ use crate::{ rpc::utils::{adjust_default_transaction_config, get_default_transaction_config}, surfnet::{ FINALIZATION_SLOT_THRESHOLD, GetAccountResult, GetTransactionResult, - locker::SvmAccessContext, svm::MAX_RECENT_BLOCKHASHES_STANDARD, + locker::{SvmAccessContext, TransactionPreflightError}, + svm::MAX_RECENT_BLOCKHASHES_STANDARD, }, - types::{SurfnetTransactionStatus, surfpool_tx_metadata_to_litesvm_tx_metadata}, + types::SurfnetTransactionStatus, }; const MAX_PRIORITIZATION_FEE_BLOCKS_CACHE: usize = 150; @@ -64,6 +67,44 @@ pub struct SurfpoolRpcSendTransactionConfig { pub skip_sig_verify: Option, } +fn build_send_transaction_simulation_failure_error( + error: &TransactionError, + metadata: &TransactionMetadata, + tx_message: &VersionedMessage, +) -> Result { + Ok(Error { + data: Some( + serde_json::to_value(get_simulate_transaction_result( + metadata.clone(), + None, + Some(error.clone()), + None, + false, + tx_message, + None, + None, + )) + .map_err(|e| { + Error::invalid_params(format!("Failed to serialize simulation result: {e}")) + })?, + ), + message: format!( + "Transaction simulation failed: {}{}", + error, + if metadata.logs.is_empty() { + String::new() + } else { + format!( + ": {} log messages:\n{}", + metadata.logs.len(), + metadata.logs.iter().map(|log| log.to_string()).join("\n") + ) + } + ), + code: jsonrpc_core::ErrorCode::ServerError(-32002), + }) +} + #[rpc] pub trait Full { type Metadata; @@ -523,7 +564,7 @@ pub trait Full { meta: Self::Metadata, data: String, config: Option, - ) -> Result; + ) -> BoxFuture>; /// Simulates a transaction without sending it to the network. /// @@ -1566,122 +1607,172 @@ impl Full for SurfpoolFullRpc { meta: Self::Metadata, data: String, config: Option, - ) -> Result { + ) -> BoxFuture> { #[cfg(feature = "prometheus")] let rpc_start = std::time::Instant::now(); let config = config.unwrap_or_default(); - let unsanitized_tx = decode_rpc_versioned_transaction(data, config.base.encoding)?; + let unsanitized_tx = match decode_rpc_versioned_transaction(data, config.base.encoding) { + Ok(tx) => tx, + Err(e) => return Box::pin(async move { Err(e) }), + }; let signatures = unsanitized_tx.signatures.clone(); let signature = signatures[0]; // Clone the message before moving the transaction, as we'll need it for error reporting let tx_message = unsanitized_tx.message.clone(); let Some(ctx) = meta else { - return Err(RpcCustomError::NodeUnhealthy { - num_slots_behind: None, - } - .into()); + return Box::pin(async move { + Err(RpcCustomError::NodeUnhealthy { + num_slots_behind: None, + } + .into()) + }); }; - let (status_update_tx, status_update_rx) = crossbeam_channel::bounded(1); - ctx.simnet_commands_tx - .send(SimnetCommand::ProcessTransaction( - ctx.id, - unsanitized_tx, - status_update_tx, - config.base.skip_preflight, - config.skip_sig_verify, - )) - .map_err(|_| RpcCustomError::NodeUnhealthy { - num_slots_behind: None, - })?; + let remote_ctx = ctx.remote_rpc_client.clone().map(|client| { + ( + client, + config + .base + .preflight_commitment + .map(|commitment| CommitmentConfig { commitment }) + .unwrap_or_else(CommitmentConfig::confirmed), + ) + }); + let svm_locker = ctx.svm_locker.clone(); + let simnet_commands_tx = ctx.simnet_commands_tx.clone(); + let id = ctx.id; - match status_update_rx.recv() { - Ok(TransactionStatusEvent::SimulationFailure((error, metadata))) => { - #[cfg(feature = "prometheus")] - if let Some(m) = crate::telemetry::metrics() { - m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); - m.record_rpc_request("sendTransaction", rpc_start.elapsed().as_millis() as u64); - } - return Err(Error { - data: Some( - serde_json::to_value(get_simulate_transaction_result( - surfpool_tx_metadata_to_litesvm_tx_metadata(&metadata), - None, - Some(error.clone()), - None, - false, - &tx_message, - None, // No loaded addresses available in error reporting context - None, - )) - .map_err(|e| { - Error::invalid_params(format!( - "Failed to serialize simulation result: {e}" - )) - })?, - ), - message: format!( - "Transaction simulation failed: {}{}", - error, - if metadata.logs.is_empty() { - String::new() - } else { - format!( - ": {} log messages:\n{}", - metadata.logs.len(), - metadata.logs.iter().map(|l| l.to_string()).join("\n") - ) - } - ), - code: jsonrpc_core::ErrorCode::ServerError(-32002), - }); - } - Ok(TransactionStatusEvent::ExecutionFailure(_)) => { - #[cfg(feature = "prometheus")] - if let Some(m) = crate::telemetry::metrics() { - m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); + Box::pin(async move { + let global_skip_sig_verify = + svm_locker.with_svm_reader(|svm_reader| svm_reader.skip_signature_verification); + let skip_sig_verify = config.skip_sig_verify.unwrap_or(global_skip_sig_verify); + let sigverify = !skip_sig_verify; + let uses_durable_nonce = svm_locker.with_svm_reader(|svm_reader| { + svm_reader.transaction_uses_durable_nonce(&unsanitized_tx) + }); + let blockhash_validation = if uses_durable_nonce { + TransactionBlockhashValidationMode::ValidateAtExecution + } else { + TransactionBlockhashValidationMode::ValidatedRecentBlockhashAtAdmission + }; + + if sigverify { + if let Err(err) = + svm_locker.with_svm_reader(|svm_reader| svm_reader.sigverify(&unsanitized_tx)) + { + #[cfg(feature = "prometheus")] + if let Some(m) = crate::telemetry::metrics() { + m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); + m.record_rpc_request( + "sendTransaction", + rpc_start.elapsed().as_millis() as u64, + ); + } + return Err(Error { + data: None, + message: format!("Transaction verification failed for transaction {err:?}"), + code: jsonrpc_core::ErrorCode::ServerError(-32002), + }); } } - Ok(TransactionStatusEvent::VerificationFailure(signature)) => { - #[cfg(feature = "prometheus")] - if let Some(m) = crate::telemetry::metrics() { - m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); - m.record_rpc_request("sendTransaction", rpc_start.elapsed().as_millis() as u64); - } - return Err(Error { - data: None, - message: format!("Transaction verification failed for transaction {signature}"), - code: jsonrpc_core::ErrorCode::ServerError(-32002), + + if !uses_durable_nonce { + let is_valid = svm_locker.with_svm_reader(|svm_reader| { + svm_reader.skip_blockhash_check + || svm_reader.is_recent_blockhash_valid_for_processing( + unsanitized_tx.message.recent_blockhash(), + ) }); - } - Err(e) => { - #[cfg(feature = "prometheus")] - if let Some(m) = crate::telemetry::metrics() { - m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); - m.record_rpc_request("sendTransaction", rpc_start.elapsed().as_millis() as u64); + if !is_valid { + #[cfg(feature = "prometheus")] + if let Some(m) = crate::telemetry::metrics() { + m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); + m.record_rpc_request( + "sendTransaction", + rpc_start.elapsed().as_millis() as u64, + ); + } + return Err(build_send_transaction_simulation_failure_error( + &TransactionError::BlockhashNotFound, + &TransactionMetadata::default(), + &tx_message, + )?); } - return Err(Error { - data: None, - message: format!("Failed to process transaction: {e}"), - code: jsonrpc_core::ErrorCode::ServerError(-32002), - }); } - Ok(TransactionStatusEvent::Success(_)) => - { - #[cfg(feature = "prometheus")] - if let Some(m) = crate::telemetry::metrics() { - m.record_transaction(true, rpc_start.elapsed().as_millis() as u64); + + if !config.base.skip_preflight { + match svm_locker + .preflight_transaction( + &remote_ctx, + unsanitized_tx.clone(), + false, + blockhash_validation, + ) + .await + { + Ok(()) => {} + Err(TransactionPreflightError::SimulationFailure(failed)) => { + #[cfg(feature = "prometheus")] + if let Some(m) = crate::telemetry::metrics() { + m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); + m.record_rpc_request( + "sendTransaction", + rpc_start.elapsed().as_millis() as u64, + ); + } + return Err(build_send_transaction_simulation_failure_error( + &failed.err, + &failed.meta, + &tx_message, + )?); + } + Err(TransactionPreflightError::VerificationFailure(err)) => { + #[cfg(feature = "prometheus")] + if let Some(m) = crate::telemetry::metrics() { + m.record_transaction(false, rpc_start.elapsed().as_millis() as u64); + m.record_rpc_request( + "sendTransaction", + rpc_start.elapsed().as_millis() as u64, + ); + } + return Err(Error { + data: None, + message: format!( + "Transaction verification failed for transaction {err}" + ), + code: jsonrpc_core::ErrorCode::ServerError(-32002), + }); + } } } - } - #[cfg(feature = "prometheus")] - if let Some(m) = crate::telemetry::metrics() { - m.record_rpc_request("sendTransaction", rpc_start.elapsed().as_millis() as u64); - } - Ok(signature.to_string()) + let (status_update_tx, status_update_rx) = crossbeam_channel::bounded(1); + simnet_commands_tx + .send(SimnetCommand::ProcessTransaction( + ProcessTransactionRequest { + id, + transaction: unsanitized_tx, + status_tx: status_update_tx, + skip_preflight: true, + skip_sig_verify: config.skip_sig_verify, + blockhash_validation, + }, + )) + .map_err(|_| RpcCustomError::NodeUnhealthy { + num_slots_behind: None, + })?; + + drop(status_update_rx); + + #[cfg(feature = "prometheus")] + if let Some(m) = crate::telemetry::metrics() { + m.record_transaction(true, rpc_start.elapsed().as_millis() as u64); + m.record_rpc_request("sendTransaction", rpc_start.elapsed().as_millis() as u64); + } + Ok(signature.to_string()) + }) } fn simulate_transaction( diff --git a/crates/core/src/rpc/jito.rs b/crates/core/src/rpc/jito.rs index 26c19d25e..fc0066a2b 100644 --- a/crates/core/src/rpc/jito.rs +++ b/crates/core/src/rpc/jito.rs @@ -13,7 +13,7 @@ use solana_transaction_status::{TransactionConfirmationStatus, UiTransactionEnco use surfpool_types::{ JitoBundleStatus, RpcBundleExecutionError, RpcBundleRequest, RpcBundleSimulationSummary, RpcSimulateBundleConfig, RpcSimulateBundleResult, RpcSimulateBundleTransactionResult, - TransactionStatusEvent, + TransactionBlockhashValidationMode, TransactionStatusEvent, }; use super::{RunloopContext, utils::decode_and_deserialize}; @@ -336,12 +336,13 @@ impl Jito for SurfpoolJitoRpc { // HTTP worker thread is already inside a tokio runtime and `block_on` on the // current handle panics with "Cannot start a runtime from within a runtime". let process_res = sandbox_locker - .process_transaction( + .process_transaction_with_blockhash_validation( remote_ctx, tx.clone(), status_tx, skip_preflight, sigverify, + TransactionBlockhashValidationMode::ValidateAtExecution, ) .await; @@ -825,6 +826,7 @@ impl Jito for SurfpoolJitoRpc { // caller asked for it. Avoids double-work on the hot path. false, true, // do_propagate -> status_rx receives typed errors + TransactionBlockhashValidationMode::ValidateAtExecution, ) .await; diff --git a/crates/core/src/runloops/mod.rs b/crates/core/src/runloops/mod.rs index f848f4154..4bf7879ed 100644 --- a/crates/core/src/runloops/mod.rs +++ b/crates/core/src/runloops/mod.rs @@ -478,10 +478,17 @@ pub async fn start_block_production_runloop( block_production_mode = update; continue } - SimnetCommand::ProcessTransaction(_key, transaction, status_tx, skip_preflight, skip_sig_verify_override) => { - let skip_sig_verify = skip_sig_verify_override.unwrap_or(global_skip_sig_verify); + SimnetCommand::ProcessTransaction(request) => { + let skip_sig_verify = request.skip_sig_verify.unwrap_or(global_skip_sig_verify); let sigverify = !skip_sig_verify; - if let Err(e) = svm_locker.process_transaction(&remote_client_with_commitment, transaction, status_tx, skip_preflight, sigverify).await { + if let Err(e) = svm_locker.process_transaction_with_blockhash_validation( + &remote_client_with_commitment, + request.transaction, + request.status_tx, + request.skip_preflight, + sigverify, + request.blockhash_validation, + ).await { let _ = svm_locker.simnet_events_tx().send(SimnetEvent::error(format!("Failed to process transaction: {}", e))); } if block_production_mode.eq(&BlockProductionMode::Transaction) { diff --git a/crates/core/src/surfnet/locker.rs b/crates/core/src/surfnet/locker.rs index 18b98f4f7..bf0558399 100644 --- a/crates/core/src/surfnet/locker.rs +++ b/crates/core/src/surfnet/locker.rs @@ -54,8 +54,8 @@ use solana_transaction_status::{ use surfpool_types::{ AccountSnapshot, ComputeUnitsEstimationResult, ExecutionCapture, ExportSnapshotConfig, Idl, KeyedProfileResult, ProfileResult, RpcProfileResultConfig, RunbookExecutionStatusReport, - SimnetCommand, SimnetEvent, TransactionConfirmationStatus, TransactionStatusEvent, - UiKeyedProfileResult, UuidOrSignature, VersionedIdl, + SimnetCommand, SimnetEvent, TransactionBlockhashValidationMode, TransactionConfirmationStatus, + TransactionStatusEvent, UiKeyedProfileResult, UuidOrSignature, VersionedIdl, }; use tokio::sync::RwLock; use txtx_addon_kit::indexmap::IndexSet; @@ -83,6 +83,11 @@ enum ProcessTransactionResult { ExecutionFailure(FailedTransactionMetadata), } +pub enum TransactionPreflightError { + SimulationFailure(FailedTransactionMetadata), + VerificationFailure(String), +} + pub struct SvmAccessContext { pub slot: Slot, pub latest_epoch_info: EpochInfo, @@ -1166,6 +1171,73 @@ impl SurfnetSvmLocker { }) } + pub async fn preflight_transaction( + &self, + remote_ctx: &Option<(SurfnetRemoteClient, CommitmentConfig)>, + transaction: VersionedTransaction, + sigverify: bool, + blockhash_validation: TransactionBlockhashValidationMode, + ) -> Result<(), TransactionPreflightError> { + let tx_loaded_addresses = self + .get_loaded_addresses(remote_ctx, &transaction.message) + .await + .map_err(|e| TransactionPreflightError::VerificationFailure(e.to_string()))?; + + if let Some(ref loaded) = tx_loaded_addresses { + let static_keys: HashSet<&Pubkey> = + transaction.message.static_account_keys().iter().collect(); + for loaded_key in loaded.all_loaded_addresses() { + if static_keys.contains(loaded_key) { + return Err(TransactionPreflightError::SimulationFailure( + FailedTransactionMetadata { + err: TransactionError::AccountLoadedTwice, + meta: TransactionMetadata::default(), + }, + )); + } + } + } + + let transaction_accounts = self.get_pubkeys_from_message( + &transaction.message, + tx_loaded_addresses + .as_ref() + .map(|loaded| loaded.all_loaded_addresses()), + ); + + let account_updates = self + .get_multiple_accounts(remote_ctx, &transaction_accounts, None) + .await + .map_err(|e| TransactionPreflightError::VerificationFailure(e.to_string()))? + .inner; + + let alt_account_updates = self + .get_multiple_accounts( + remote_ctx, + &tx_loaded_addresses + .as_ref() + .map(|loaded| loaded.alt_addresses()) + .unwrap_or_default(), + None, + ) + .await + .map_err(|e| TransactionPreflightError::VerificationFailure(e.to_string()))? + .inner; + + self.write_multiple_account_updates(&account_updates); + self.write_multiple_account_updates(&alt_account_updates); + + self.with_svm_reader(|svm_reader| { + svm_reader.simulate_transaction_with_blockhash_validation( + transaction, + sigverify, + blockhash_validation, + ) + }) + .map(|_| ()) + .map_err(TransactionPreflightError::SimulationFailure) + } + pub fn is_instruction_profiling_enabled(&self) -> bool { self.with_svm_reader(|svm_reader| svm_reader.instruction_profiling_enabled) } @@ -1181,6 +1253,26 @@ impl SurfnetSvmLocker { status_tx: Sender, skip_preflight: bool, sigverify: bool, + ) -> SurfpoolResult<()> { + self.process_transaction_with_blockhash_validation( + remote_ctx, + transaction, + status_tx, + skip_preflight, + sigverify, + TransactionBlockhashValidationMode::ValidateAtExecution, + ) + .await + } + + pub async fn process_transaction_with_blockhash_validation( + &self, + remote_ctx: &Option<(SurfnetRemoteClient, CommitmentConfig)>, + transaction: VersionedTransaction, + status_tx: Sender, + skip_preflight: bool, + sigverify: bool, + blockhash_validation: TransactionBlockhashValidationMode, ) -> SurfpoolResult<()> { let do_propagate_status_updates = true; let signature = transaction.signatures[0]; @@ -1192,6 +1284,7 @@ impl SurfnetSvmLocker { skip_preflight, sigverify, do_propagate_status_updates, + blockhash_validation, ) .await { @@ -1250,6 +1343,7 @@ impl SurfnetSvmLocker { skip_preflight, sigverify, do_propagate_status_updates, + TransactionBlockhashValidationMode::ValidateAtExecution, ) .await?; @@ -1271,6 +1365,7 @@ impl SurfnetSvmLocker { skip_preflight: bool, sigverify: bool, do_propagate: bool, + blockhash_validation: TransactionBlockhashValidationMode, ) -> SurfpoolResult { let signature = transaction.signatures[0]; @@ -1465,6 +1560,7 @@ impl SurfnetSvmLocker { pre_execution_capture, &status_tx, do_propagate, + blockhash_validation, ) .await?; @@ -1563,6 +1659,7 @@ impl SurfnetSvmLocker { pre_execution_capture_cursor, status_tx, do_propagate, + TransactionBlockhashValidationMode::ValidateAtExecution, ) .await?; @@ -1983,9 +2080,15 @@ impl SurfnetSvmLocker { pre_execution_capture: ExecutionCapture, status_tx: &Sender, do_propagate: bool, + blockhash_validation: TransactionBlockhashValidationMode, ) -> SurfpoolResult { let res = match self - .do_process_transaction_internal(transaction.clone(), skip_preflight, sigverify) + .do_process_transaction_internal( + transaction.clone(), + skip_preflight, + sigverify, + blockhash_validation, + ) .await { ProcessTransactionResult::Success(transaction_metadata) => self @@ -2033,12 +2136,17 @@ impl SurfnetSvmLocker { transaction: VersionedTransaction, skip_preflight: bool, sigverify: bool, + blockhash_validation: TransactionBlockhashValidationMode, ) -> ProcessTransactionResult { // if not skipping preflight, simulate the transaction if !skip_preflight { if let Err(e) = self.with_svm_reader(|svm_reader| { svm_reader - .simulate_transaction(transaction.clone(), sigverify) + .simulate_transaction_with_blockhash_validation( + transaction.clone(), + sigverify, + blockhash_validation, + ) .map_err(ProcessTransactionResult::SimulationFailure) }) { return e; @@ -2047,7 +2155,12 @@ impl SurfnetSvmLocker { match self.with_svm_writer(|svm_writer| { svm_writer - .send_transaction(transaction, false /* cu_analysis_enabled */, sigverify) + .send_transaction_with_blockhash_validation( + transaction, + false, /* cu_analysis_enabled */ + sigverify, + blockhash_validation, + ) .map_err(|e| { debug!("Transaction execution failure: {:?}", e.meta); ProcessTransactionResult::ExecutionFailure(e) diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index 30ae65d2d..e1208b2b2 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -28,7 +28,7 @@ use solana_client::{ rpc_filter::RpcFilterType, rpc_response::{RpcKeyedAccount, RpcLogsResponse, RpcPerfSample}, }; -use solana_clock::{Clock, Slot}; +use solana_clock::{Clock, MAX_PROCESSING_AGE, Slot}; use solana_commitment_config::{CommitmentConfig, CommitmentLevel}; use solana_epoch_info::EpochInfo; use solana_epoch_schedule::EpochSchedule; @@ -57,8 +57,9 @@ use surfpool_types::{ AccountChange, AccountProfileState, AccountSnapshot, DEFAULT_PROFILING_MAP_CAPACITY, DEFAULT_SLOT_TIME_MS, ExportSnapshotConfig, ExportSnapshotScope, FifoMap, Idl, OverrideInstance, ProfileResult, RpcProfileDepth, RpcProfileResultConfig, - RunbookExecutionStatusReport, SimnetEvent, SvmFeatureConfig, TransactionConfirmationStatus, - TransactionStatusEvent, UiAccountChange, UiAccountProfileState, UiProfileResult, VersionedIdl, + RunbookExecutionStatusReport, SimnetEvent, SvmFeatureConfig, + TransactionBlockhashValidationMode, TransactionConfirmationStatus, TransactionStatusEvent, + UiAccountChange, UiAccountProfileState, UiProfileResult, VersionedIdl, types::{ ComputeUnitsEstimationResult, KeyedProfileResult, UiKeyedProfileResult, UuidOrSignature, }, @@ -226,6 +227,7 @@ pub struct SurfnetSvmConfig { pub max_profiles: usize, pub log_bytes_limit: Option, pub skip_blockhash_check: bool, + pub skip_signature_verification: bool, } impl Default for SurfnetSvmConfig { @@ -238,6 +240,7 @@ impl Default for SurfnetSvmConfig { max_profiles: DEFAULT_PROFILING_MAP_CAPACITY, log_bytes_limit: DEFAULT_LOG_BYTES_LIMIT, skip_blockhash_check: false, + skip_signature_verification: false, } } } @@ -310,6 +313,7 @@ pub struct SurfnetSvm { pub instruction_profiling_enabled: bool, pub max_profiles: usize, pub skip_blockhash_check: bool, + pub skip_signature_verification: bool, pub runbook_executions: Vec, pub account_update_slots: HashMap, pub streamed_accounts: Box>, @@ -582,6 +586,7 @@ impl SurfnetSvm { instruction_profiling_enabled: self.instruction_profiling_enabled, max_profiles: self.max_profiles, skip_blockhash_check: self.skip_blockhash_check, + skip_signature_verification: self.skip_signature_verification, runbook_executions: self.runbook_executions.clone(), account_update_slots: self.account_update_slots.clone(), recent_blockhashes: self.recent_blockhashes.clone(), @@ -1035,6 +1040,7 @@ impl SurfnetSvm { instruction_profiling_enabled: config.instruction_profiling_enabled, max_profiles: config.max_profiles, skip_blockhash_check: config.skip_blockhash_check, + skip_signature_verification: config.skip_signature_verification, runbook_executions: Vec::new(), account_update_slots: HashMap::new(), streamed_accounts: streamed_accounts_db, @@ -1472,6 +1478,58 @@ impl SurfnetSvm { .any(|entry| entry.blockhash == *recent_blockhash) } + /// Returns the age of a recent blockhash in Surfpool's recent-blockhash window. + /// + /// Age 0 is the current blockhash, age 1 is the previous blockhash, and so on. + pub fn recent_blockhash_age(&self, recent_blockhash: &Hash) -> Option { + #[allow(deprecated)] + self.inner + .get_sysvar::() + .iter() + .position(|entry| entry.blockhash == *recent_blockhash) + .map(|age| age as u64) + } + + pub fn last_valid_block_height_for_hash(&self, recent_blockhash: &Hash) -> Option { + let remaining_block_heights = (MAX_PROCESSING_AGE as u64) + .checked_sub(self.recent_blockhash_age(recent_blockhash)?)?; + Some( + self.latest_epoch_info + .block_height + .saturating_add(remaining_block_heights), + ) + } + + pub fn is_recent_blockhash_valid_for_processing(&self, recent_blockhash: &Hash) -> bool { + self.recent_blockhash_age(recent_blockhash) + .is_some_and(|age| age <= MAX_PROCESSING_AGE as u64) + } + + pub fn transaction_uses_durable_nonce(&self, tx: &VersionedTransaction) -> bool { + self.nonce_account_pubkey(tx).is_some() + } + + fn nonce_account_pubkey<'a>(&self, tx: &'a VersionedTransaction) -> Option<&'a Pubkey> { + let instruction = tx + .message + .instructions() + .get(solana_nonce::NONCED_TX_MARKER_IX_INDEX as usize)?; + + let program_id = tx + .message + .static_account_keys() + .get(instruction.program_id_index as usize)?; + if !system_program::check_id(program_id) + || !is_advance_nonce_instruction_data(&instruction.data) + { + return None; + } + + tx.message + .static_account_keys() + .get(*instruction.accounts.first()? as usize) + } + /// Validates the blockhash of a transaction, considering nonce accounts if present. /// If the transaction uses a nonce account, the blockhash is validated against the nonce account's stored blockhash. /// Otherwise, it is validated against the RecentBlockhashes sysvar. @@ -1482,50 +1540,32 @@ impl SurfnetSvm { /// # Returns /// `true` if the transaction blockhash is valid, `false` otherwise. pub fn validate_transaction_blockhash(&self, tx: &VersionedTransaction) -> bool { + self.validate_transaction_blockhash_with_mode( + tx, + TransactionBlockhashValidationMode::ValidateAtExecution, + ) + } + + pub fn validate_transaction_blockhash_with_mode( + &self, + tx: &VersionedTransaction, + blockhash_validation: TransactionBlockhashValidationMode, + ) -> bool { if self.skip_blockhash_check { return true; } let recent_blockhash = tx.message.recent_blockhash(); - let some_nonce_account_index = tx - .message - .instructions() - .get(solana_nonce::NONCED_TX_MARKER_IX_INDEX as usize) - .filter(|instruction| { - matches!( - tx.message.static_account_keys().get(instruction.program_id_index as usize), - Some(program_id) if system_program::check_id(program_id) - ) && is_advance_nonce_instruction_data(&instruction.data) - }) - .map(|instruction| { - // nonce account is the first account in the instruction - instruction.accounts.get(0) - }); + let nonce_account_pubkey = self.nonce_account_pubkey(tx); debug!( "Validating tx blockhash: {}; is nonce tx?: {}", recent_blockhash, - some_nonce_account_index.is_some() + nonce_account_pubkey.is_some() ); - if let Some(nonce_account_index) = some_nonce_account_index { - trace!( - "Nonce tx detected. Nonce account index: {:?}", - nonce_account_index - ); - let Some(nonce_account_index) = nonce_account_index else { - return false; - }; - - let Some(nonce_account_pubkey) = tx - .message - .static_account_keys() - .get(*nonce_account_index as usize) - else { - return false; - }; - + if let Some(nonce_account_pubkey) = nonce_account_pubkey { trace!("Nonce account pubkey: {:?}", nonce_account_pubkey,); // Here we're swallowing errors in the storage - if we fail to fetch the account because of a storage error, @@ -1548,9 +1588,15 @@ impl SurfnetSvm { solana_nonce::state::State::Initialized(data) => data, }; return initialized_state.blockhash() == *recent_blockhash; - } else { - self.check_blockhash_is_recent(recent_blockhash) } + + if blockhash_validation + == TransactionBlockhashValidationMode::ValidatedRecentBlockhashAtAdmission + { + return true; + } + + self.is_recent_blockhash_valid_for_processing(recent_blockhash) } /// Verifies the signature of a transaction and validates that it hasn't already been processed. @@ -1943,6 +1989,22 @@ impl SurfnetSvm { tx: VersionedTransaction, cu_analysis_enabled: bool, sigverify: bool, + ) -> TransactionResult { + self.send_transaction_with_blockhash_validation( + tx, + cu_analysis_enabled, + sigverify, + TransactionBlockhashValidationMode::ValidateAtExecution, + ) + } + + #[allow(clippy::result_large_err)] + pub fn send_transaction_with_blockhash_validation( + &mut self, + tx: VersionedTransaction, + cu_analysis_enabled: bool, + sigverify: bool, + blockhash_validation: TransactionBlockhashValidationMode, ) -> TransactionResult { if sigverify { self.sigverify(&tx)?; @@ -1963,7 +2025,7 @@ impl SurfnetSvm { } self.transactions_processed += 1; - if !self.validate_transaction_blockhash(&tx) { + if !self.validate_transaction_blockhash_with_mode(&tx, blockhash_validation) { let meta = TransactionMetadata::default(); let err = solana_transaction_error::TransactionError::BlockhashNotFound; @@ -2047,12 +2109,26 @@ impl SurfnetSvm { &self, tx: VersionedTransaction, sigverify: bool, + ) -> Result { + self.simulate_transaction_with_blockhash_validation( + tx, + sigverify, + TransactionBlockhashValidationMode::ValidateAtExecution, + ) + } + + #[allow(clippy::result_large_err)] + pub fn simulate_transaction_with_blockhash_validation( + &self, + tx: VersionedTransaction, + sigverify: bool, + blockhash_validation: TransactionBlockhashValidationMode, ) -> Result { if sigverify { self.sigverify(&tx)?; } - if !self.validate_transaction_blockhash(&tx) { + if !self.validate_transaction_blockhash_with_mode(&tx, blockhash_validation) { let meta = TransactionMetadata::default(); let err = TransactionError::BlockhashNotFound; diff --git a/crates/sdk/src/surfnet.rs b/crates/sdk/src/surfnet.rs index 9fc81d944..03ba96e41 100644 --- a/crates/sdk/src/surfnet.rs +++ b/crates/sdk/src/surfnet.rs @@ -191,6 +191,7 @@ impl SurfnetBuilder { log_bytes_limit: surfpool_config.simnets[0].log_bytes_limit, feature_config, skip_blockhash_check, + skip_signature_verification: surfpool_config.simnets[0].skip_signature_verification, }; let (surfnet_svm, simnet_events_rx, geyser_events_rx) = SurfnetSvm::new(svm_config) .map_err(|e| SurfnetError::Runtime(format!("failed to initialize Surfnet SVM: {e}")))?; diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index 54943de43..76bc61d0a 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -539,6 +539,22 @@ pub enum TransactionStatusEvent { VerificationFailure(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransactionBlockhashValidationMode { + ValidateAtExecution, + ValidatedRecentBlockhashAtAdmission, +} + +#[derive(Debug)] +pub struct ProcessTransactionRequest { + pub id: Option<(Hash, String)>, + pub transaction: VersionedTransaction, + pub status_tx: Sender, + pub skip_preflight: bool, + pub skip_sig_verify: Option, + pub blockhash_validation: TransactionBlockhashValidationMode, +} + #[derive(Debug)] pub enum SimnetCommand { SlotForward(Option), @@ -547,13 +563,7 @@ pub enum SimnetCommand { UpdateInternalClock(Option<(Hash, String)>, Clock), UpdateInternalClockWithConfirmation(Option<(Hash, String)>, Clock, Sender), UpdateBlockProductionMode(BlockProductionMode), - ProcessTransaction( - Option<(Hash, String)>, - VersionedTransaction, - Sender, - bool, - Option, - ), + ProcessTransaction(ProcessTransactionRequest), Terminate(Option<(Hash, String)>), StartRunbookExecution(String), CompleteRunbookExecution(String, Option>), From 423482b7d91cf346129b9ce3a8d4ce1708233c38 Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Thu, 25 Jun 2026 22:24:29 -0400 Subject: [PATCH 2/3] fix(core): derive accurate lastValidBlockHeight in getLatestBlockhash getLatestBlockhash now returns the committed slot's blockhash together with a lastValidBlockHeight computed from that blockhash's actual age in the recent-blockhash window, instead of assuming a fixed MAX_RECENT_BLOCKHASHES offset. isBlockhashValid likewise uses the age-aware is_recent_blockhash_valid_for_processing check so an expired-by-age blockhash is correctly reported as invalid. --- crates/core/src/rpc/full.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/core/src/rpc/full.rs b/crates/core/src/rpc/full.rs index 3376cb97f..1731daddf 100644 --- a/crates/core/src/rpc/full.rs +++ b/crates/core/src/rpc/full.rs @@ -51,7 +51,6 @@ use crate::{ surfnet::{ FINALIZATION_SLOT_THRESHOLD, GetAccountResult, GetTransactionResult, locker::{SvmAccessContext, TransactionPreflightError}, - svm::MAX_RECENT_BLOCKHASHES_STANDARD, }, types::SurfnetTransactionStatus, }; @@ -2348,12 +2347,17 @@ impl Full for SurfpoolFullRpc { } } - let blockhash = svm_locker - .get_latest_blockhash(&commitment) - .unwrap_or_else(|| svm_locker.latest_absolute_blockhash()); + let (blockhash, last_valid_block_height) = svm_locker.with_svm_reader(|svm_reader| { + let blockhash = svm_reader + .blockhash_for_slot(committed_latest_slot) + .filter(|hash| svm_reader.is_recent_blockhash_valid_for_processing(hash)) + .unwrap_or_else(|| svm_reader.latest_blockhash()); + let last_valid_block_height = svm_reader + .last_valid_block_height_for_hash(&blockhash) + .unwrap_or_else(|| svm_reader.latest_epoch_info().block_height); + (blockhash, last_valid_block_height) + }); - let current_block_height = svm_locker.get_epoch_info().block_height; - let last_valid_block_height = current_block_height + MAX_RECENT_BLOCKHASHES_STANDARD as u64; Ok(RpcResponse { context: RpcResponseContext::new(svm_locker.get_latest_absolute_slot()), value: RpcBlockhash { @@ -2379,8 +2383,9 @@ impl Full for SurfpoolFullRpc { let committed_latest_slot = svm_locker.get_slot_for_commitment(&config.commitment.unwrap_or_default()); - let is_valid = - svm_locker.with_svm_reader(|svm_reader| svm_reader.check_blockhash_is_recent(&hash)); + let is_valid = svm_locker.with_svm_reader(|svm_reader| { + svm_reader.is_recent_blockhash_valid_for_processing(&hash) + }); if let Some(min_context_slot) = config.min_context_slot { if committed_latest_slot < min_context_slot { From 34e9ac47ed13201e941644b958b7bb0803c1d90e Mon Sep 17 00:00:00 2001 From: Micaiah Reid Date: Thu, 25 Jun 2026 22:24:59 -0400 Subject: [PATCH 3/3] test(core): cover async transaction execution and blockhash handling Adapts existing tests to the asynchronous, non-blocking sendTransaction (await the future, drop the join-handle wait) and adds coverage for: transactions and loader write batches surviving internal blockhash expiry, rejecting an invalid blockhash at admission, accepting an invalid blockhash when --skip-blockhash-check is set, honoring the global --skip-signature-verification flag at admission, is_blockhash_valid expiry-by-age, and the age-aware getLatestBlockhash response. --- crates/core/src/rpc/full.rs | 604 ++++++++++++++++++++++++++------- crates/core/src/rpc/jito.rs | 4 +- crates/core/src/surfnet/svm.rs | 3 + 3 files changed, 480 insertions(+), 131 deletions(-) diff --git a/crates/core/src/rpc/full.rs b/crates/core/src/rpc/full.rs index 1731daddf..ca80e9407 100644 --- a/crates/core/src/rpc/full.rs +++ b/crates/core/src/rpc/full.rs @@ -2642,17 +2642,20 @@ fn get_simulate_transaction_result( mod tests { pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000; - use std::thread::JoinHandle; - use base64::{Engine, prelude::BASE64_STANDARD}; use bincode::Options; use crossbeam_channel::Receiver; + use solana_account::Account; use solana_account_decoder::{UiAccount, UiAccountData, UiAccountEncoding}; use solana_client::rpc_config::RpcSimulateTransactionAccountsConfig; + use solana_clock::MAX_PROCESSING_AGE; use solana_commitment_config::CommitmentConfig; use solana_hash::Hash; use solana_instruction::Instruction; use solana_keypair::Keypair; + use solana_loader_v3_interface::{ + instruction as loader_v3_instruction, state::UpgradeableLoaderState, + }; use solana_message::{ MessageHeader, legacy::Message as LegacyMessage, v0::Message as V0Message, }; @@ -2671,7 +2674,7 @@ mod tests { EncodedTransaction, EncodedTransactionWithStatusMeta, UiCompiledInstruction, UiMessage, UiRawMessage, UiTransaction, UiTransactionEncoding, }; - use surfpool_types::{SimnetCommand, TransactionConfirmationStatus}; + use surfpool_types::{SimnetCommand, TransactionConfirmationStatus, TransactionStatusEvent}; use test_case::test_case; use super::*; @@ -2711,25 +2714,22 @@ mod tests { tx: VersionedTransaction, setup: TestSetup, mempool_rx: Receiver, - ) -> JoinHandle { - let setup_clone = setup.clone(); - let handle = hiro_system_kit::thread_named("send_tx") - .spawn(move || { - let res = setup_clone - .rpc - .send_transaction( - Some(setup_clone.context), - bs58::encode(bincode::serialize(&tx).unwrap()).into_string(), - None, - ) - .unwrap(); - - res - }) + ) -> String { + let res = setup + .rpc + .send_transaction( + Some(setup.context.clone()), + bs58::encode(bincode::serialize(&tx).unwrap()).into_string(), + None, + ) + .await .unwrap(); + loop { match mempool_rx.recv() { - Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => { + Ok(SimnetCommand::ProcessTransaction(request)) => { + let tx = request.transaction; + let status_tx = request.status_tx; let mut writer = setup.context.svm_locker.0.write().await; let slot = writer.get_latest_absolute_slot(); writer.transactions_queued_for_confirmation.push_back(( @@ -2754,11 +2754,9 @@ mod tests { ), ) .unwrap(); - status_tx - .send(TransactionStatusEvent::Success( - TransactionConfirmationStatus::Confirmed, - )) - .unwrap(); + let _ = status_tx.send(TransactionStatusEvent::Success( + TransactionConfirmationStatus::Confirmed, + )); break; } Ok(SimnetCommand::AirdropProcessed) => continue, @@ -2766,7 +2764,7 @@ mod tests { } } - handle + res } #[test_case(None, false ; "when limit is None")] @@ -3104,14 +3102,349 @@ mod tests { .await .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL); - let handle = send_and_await_transaction(tx.clone(), setup.clone(), mempool_rx).await; + let signature = send_and_await_transaction(tx.clone(), setup.clone(), mempool_rx).await; assert_eq!( - handle.join().unwrap(), + signature, tx.signatures[0].to_string(), "incorrect signature" ); } + #[tokio::test(flavor = "multi_thread")] + async fn test_rpc_accepted_transaction_survives_internal_blockhash_expiry() { + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded(); + let setup = TestSetup::new_with_mempool(SurfpoolFullRpc, mempool_tx); + let recent_blockhash = setup + .context + .svm_locker + .with_svm_reader(|svm_reader| svm_reader.latest_blockhash()); + + let _ = setup + .context + .svm_locker + .0 + .write() + .await + .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL); + + let tx = build_legacy_transaction( + &payer.pubkey(), + &[&payer.insecure_clone()], + &[system_instruction::transfer( + &payer.pubkey(), + &recipient, + LAMPORTS_PER_SOL, + )], + &recent_blockhash, + ); + + let config = SurfpoolRpcSendTransactionConfig { + base: RpcSendTransactionConfig { + skip_preflight: true, + ..Default::default() + }, + skip_sig_verify: None, + }; + let signature = setup + .rpc + .send_transaction( + Some(setup.context.clone()), + bs58::encode(bincode::serialize(&tx).unwrap()).into_string(), + Some(config), + ) + .await + .unwrap(); + assert_eq!(signature, tx.signatures[0].to_string()); + + let request = match mempool_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("expected queued transaction") + { + SimnetCommand::ProcessTransaction(request) => request, + other => panic!("unexpected simnet command: {other:?}"), + }; + + setup.context.svm_locker.with_svm_writer(|svm_writer| { + for _ in 0..=MAX_PROCESSING_AGE { + svm_writer.confirm_current_block().unwrap(); + } + assert!( + !svm_writer.is_recent_blockhash_valid_for_processing(&recent_blockhash), + "test setup should expire the accepted blockhash before execution" + ); + }); + + let (status_tx, status_rx) = crossbeam_channel::bounded(1); + setup + .context + .svm_locker + .process_transaction_with_blockhash_validation( + &None, + request.transaction, + status_tx, + request.skip_preflight, + !request.skip_sig_verify.unwrap_or(false), + request.blockhash_validation, + ) + .await + .unwrap(); + + match status_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("expected transaction status") + { + TransactionStatusEvent::Success(TransactionConfirmationStatus::Processed) => {} + TransactionStatusEvent::ExecutionFailure((TransactionError::BlockhashNotFound, _)) => { + panic!("accepted transaction failed with BlockhashNotFound during execution") + } + other => panic!("unexpected transaction status: {other:?}"), + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_loader_write_batch_survives_internal_blockhash_expiry() { + let payer = Keypair::new(); + let buffer_authority = Keypair::new(); + let buffer = Keypair::new(); + let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded(); + let setup = TestSetup::new_with_mempool(SurfpoolFullRpc, mempool_tx); + let recent_blockhash = setup + .context + .svm_locker + .with_svm_reader(|svm_reader| svm_reader.latest_blockhash()); + + let batch_len = MAX_PROCESSING_AGE + 10; + let chunk_len = 4usize; + let program_len = batch_len * chunk_len; + let mut buffer_data = bincode::serialize(&UpgradeableLoaderState::Buffer { + authority_address: Some(buffer_authority.pubkey()), + }) + .unwrap(); + buffer_data.resize(UpgradeableLoaderState::size_of_buffer(program_len), 0); + + setup.context.svm_locker.with_svm_writer(|svm_writer| { + let _ = svm_writer + .airdrop(&payer.pubkey(), 10 * LAMPORTS_PER_SOL) + .unwrap(); + svm_writer + .set_account( + &buffer.pubkey(), + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: buffer_data, + owner: solana_sdk_ids::bpf_loader_upgradeable::id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + }); + + let config = SurfpoolRpcSendTransactionConfig { + base: RpcSendTransactionConfig { + skip_preflight: true, + ..Default::default() + }, + skip_sig_verify: None, + }; + + for i in 0..batch_len { + let offset = i * chunk_len; + let bytes = vec![i as u8; chunk_len]; + let instruction = loader_v3_instruction::write( + &buffer.pubkey(), + &buffer_authority.pubkey(), + offset as u32, + bytes, + ); + let tx = VersionedTransaction::try_new( + VersionedMessage::Legacy(LegacyMessage::new_with_blockhash( + &[instruction], + Some(&payer.pubkey()), + &recent_blockhash, + )), + &[payer.insecure_clone(), buffer_authority.insecure_clone()], + ) + .unwrap(); + setup + .rpc + .send_transaction( + Some(setup.context.clone()), + bs58::encode(bincode::serialize(&tx).unwrap()).into_string(), + Some(config.clone()), + ) + .await + .unwrap(); + } + + let mut requests = Vec::with_capacity(batch_len); + for _ in 0..batch_len { + match mempool_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("expected queued loader write") + { + SimnetCommand::ProcessTransaction(request) => requests.push(request), + other => panic!("unexpected simnet command: {other:?}"), + } + } + + setup.context.svm_locker.with_svm_writer(|svm_writer| { + for _ in 0..=MAX_PROCESSING_AGE { + svm_writer.confirm_current_block().unwrap(); + } + assert!( + !svm_writer.is_recent_blockhash_valid_for_processing(&recent_blockhash), + "test setup should expire the accepted blockhash before execution" + ); + }); + + for request in requests { + let (status_tx, status_rx) = crossbeam_channel::bounded(1); + setup + .context + .svm_locker + .process_transaction_with_blockhash_validation( + &None, + request.transaction, + status_tx, + request.skip_preflight, + !request.skip_sig_verify.unwrap_or(false), + request.blockhash_validation, + ) + .await + .unwrap(); + + match status_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("expected loader write status") + { + TransactionStatusEvent::Success(TransactionConfirmationStatus::Processed) => {} + TransactionStatusEvent::ExecutionFailure(( + TransactionError::BlockhashNotFound, + _, + )) => panic!("accepted loader write failed with BlockhashNotFound"), + other => panic!("unexpected loader write status: {other:?}"), + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_send_transaction_rejects_invalid_blockhash_at_admission() { + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded(); + let setup = TestSetup::new_with_mempool(SurfpoolFullRpc, mempool_tx); + let invalid_blockhash = Hash::new_unique(); + + let _ = setup + .context + .svm_locker + .0 + .write() + .await + .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL); + + let tx = build_legacy_transaction( + &payer.pubkey(), + &[&payer.insecure_clone()], + &[system_instruction::transfer( + &payer.pubkey(), + &recipient, + LAMPORTS_PER_SOL, + )], + &invalid_blockhash, + ); + + let config = SurfpoolRpcSendTransactionConfig { + base: RpcSendTransactionConfig { + skip_preflight: true, + ..Default::default() + }, + skip_sig_verify: None, + }; + let err = setup + .rpc + .send_transaction( + Some(setup.context.clone()), + bs58::encode(bincode::serialize(&tx).unwrap()).into_string(), + Some(config), + ) + .await + .expect_err("invalid blockhash should be rejected before enqueue"); + assert!( + err.message.contains("Blockhash"), + "unexpected error: {err:?}" + ); + assert!( + mempool_rx.try_recv().is_err(), + "invalid blockhash transaction should not be enqueued" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_send_transaction_accepts_invalid_blockhash_when_skip_blockhash_check() { + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded(); + let setup = TestSetup::new_with_mempool(SurfpoolFullRpc, mempool_tx); + let invalid_blockhash = Hash::new_unique(); + + // Operator opted into `--skip-blockhash-check`; admission must honor it. + setup + .context + .svm_locker + .with_svm_writer(|svm_writer| svm_writer.skip_blockhash_check = true); + + let _ = setup + .context + .svm_locker + .0 + .write() + .await + .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL); + + let tx = build_legacy_transaction( + &payer.pubkey(), + &[&payer.insecure_clone()], + &[system_instruction::transfer( + &payer.pubkey(), + &recipient, + LAMPORTS_PER_SOL, + )], + &invalid_blockhash, + ); + + let config = SurfpoolRpcSendTransactionConfig { + base: RpcSendTransactionConfig { + skip_preflight: true, + ..Default::default() + }, + skip_sig_verify: None, + }; + let signature = setup + .rpc + .send_transaction( + Some(setup.context.clone()), + bs58::encode(bincode::serialize(&tx).unwrap()).into_string(), + Some(config), + ) + .await + .expect("skip_blockhash_check should bypass the admission blockhash check"); + assert_eq!(signature, tx.signatures[0].to_string()); + + match mempool_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("transaction should be enqueued for execution") + { + SimnetCommand::ProcessTransaction(request) => { + assert_eq!(request.transaction.signatures[0], tx.signatures[0]); + } + other => panic!("unexpected simnet command: {other:?}"), + } + } + #[test_case(TransactionVersion::Legacy(Legacy::Legacy) ; "Legacy transactions")] #[test_case(TransactionVersion::Number(0) ; "V0 transactions")] #[tokio::test(flavor = "multi_thread")] @@ -3670,77 +4003,11 @@ mod tests { insert_test_blocks(&setup, 100..=150); - // processed commitment - { - let commitment = CommitmentConfig::processed(); - let res = setup - .rpc - .get_latest_blockhash( - Some(setup.context.clone()), - Some(RpcContextConfig { - commitment: Some(commitment.clone()), - ..Default::default() - }), - ) - .unwrap(); - let expected_blockhash = setup - .context - .svm_locker - .get_latest_blockhash(&commitment) - .unwrap(); - - let current_block_height = setup.context.svm_locker.get_epoch_info().block_height; - let expected_last_valid_block_height = - current_block_height + MAX_RECENT_BLOCKHASHES_STANDARD as u64; - - assert_eq!( - res.value.blockhash, - expected_blockhash.to_string(), - "Latest blockhash does not match expected value" - ); - assert_eq!( - res.value.last_valid_block_height, expected_last_valid_block_height, - "Last valid block height does not match expected value" - ); - } - - // confirmed commitment - { - let commitment = CommitmentConfig::confirmed(); - let res = setup - .rpc - .get_latest_blockhash( - Some(setup.context.clone()), - Some(RpcContextConfig { - commitment: Some(commitment.clone()), - ..Default::default() - }), - ) - .unwrap(); - let expected_blockhash = setup - .context - .svm_locker - .get_latest_blockhash(&commitment) - .unwrap(); - - let current_block_height = setup.context.svm_locker.get_epoch_info().block_height; - let expected_last_valid_block_height = - current_block_height + MAX_RECENT_BLOCKHASHES_STANDARD as u64; - - assert_eq!( - res.value.blockhash, - expected_blockhash.to_string(), - "Latest blockhash does not match expected value" - ); - assert_eq!( - res.value.last_valid_block_height, expected_last_valid_block_height, - "Last valid block height does not match expected value" - ); - } - - // confirmed finalized - { - let commitment = CommitmentConfig::finalized(); + for commitment in [ + CommitmentConfig::processed(), + CommitmentConfig::confirmed(), + CommitmentConfig::finalized(), + ] { let res = setup .rpc .get_latest_blockhash( @@ -3751,15 +4018,21 @@ mod tests { }), ) .unwrap(); - let expected_blockhash = setup + let committed_slot = setup .context .svm_locker - .get_latest_blockhash(&commitment) - .unwrap(); - - let current_block_height = setup.context.svm_locker.get_epoch_info().block_height; - let expected_last_valid_block_height = - current_block_height + MAX_RECENT_BLOCKHASHES_STANDARD as u64; + .get_slot_for_commitment(&commitment); + let (expected_blockhash, expected_last_valid_block_height) = + setup.context.svm_locker.with_svm_reader(|svm_reader| { + let expected_blockhash = svm_reader + .blockhash_for_slot(committed_slot) + .filter(|hash| svm_reader.is_recent_blockhash_valid_for_processing(hash)) + .unwrap_or_else(|| svm_reader.latest_blockhash()); + let expected_last_valid_block_height = svm_reader + .last_valid_block_height_for_hash(&expected_blockhash) + .unwrap(); + (expected_blockhash, expected_last_valid_block_height) + }); assert_eq!( res.value.blockhash, @@ -3846,14 +4119,8 @@ mod tests { &recent_blockhash, ); - send_and_await_transaction(tx_1, setup.clone(), mempool_rx.clone()) - .await - .join() - .unwrap(); - send_and_await_transaction(tx_2, setup.clone(), mempool_rx) - .await - .join() - .unwrap(); + let _ = send_and_await_transaction(tx_1, setup.clone(), mempool_rx.clone()).await; + let _ = send_and_await_transaction(tx_2, setup.clone(), mempool_rx).await; setup .context .svm_locker @@ -4625,6 +4892,36 @@ mod tests { assert_eq!(result_processed.value, true); } + #[tokio::test(flavor = "multi_thread")] + async fn test_is_blockhash_valid_expired_by_age() { + let setup = TestSetup::new(SurfpoolFullRpc); + let recent_blockhash = setup + .context + .svm_locker + .with_svm_reader(|svm| svm.latest_blockhash()); + + setup.context.svm_locker.with_svm_writer(|svm| { + for _ in 0..=MAX_PROCESSING_AGE { + svm.confirm_current_block().unwrap(); + } + assert!( + !svm.is_recent_blockhash_valid_for_processing(&recent_blockhash), + "test setup should expire the captured blockhash" + ); + }); + + let result = setup + .rpc + .is_blockhash_valid( + Some(setup.context.clone()), + recent_blockhash.to_string(), + None, + ) + .unwrap(); + + assert_eq!(result.value, false); + } + #[tokio::test(flavor = "multi_thread")] async fn test_is_blockhash_valid_invalid_blockhash() { let setup = TestSetup::new(SurfpoolFullRpc); @@ -4939,20 +5236,16 @@ mod tests { skip_sig_verify: Some(true), }; - let setup_clone = setup.clone(); - let handle = hiro_system_kit::thread_named("send_tx_skip_verify") - .spawn(move || { - setup_clone.rpc.send_transaction( - Some(setup_clone.context), - tx_encoded, - Some(config), - ) - }) - .unwrap(); + let result = setup + .rpc + .send_transaction(Some(setup.context.clone()), tx_encoded, Some(config)) + .await; loop { match mempool_rx.recv() { - Ok(SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _)) => { + Ok(SimnetCommand::ProcessTransaction(request)) => { + let tx = request.transaction; + let status_tx = request.status_tx; let mut writer = setup.context.svm_locker.0.write().await; let slot = writer.get_latest_absolute_slot(); writer.transactions_queued_for_confirmation.push_back(( @@ -4977,18 +5270,15 @@ mod tests { ), ) .unwrap(); - status_tx - .send(TransactionStatusEvent::Success( - TransactionConfirmationStatus::Processed, - )) - .unwrap(); + let _ = status_tx.send(TransactionStatusEvent::Success( + TransactionConfirmationStatus::Processed, + )); break; } _ => continue, } } - let result = handle.join().unwrap(); assert!( result.is_ok(), "Transaction with skip_sig_verify=true should succeed: {:?}", @@ -4996,6 +5286,60 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn test_send_transaction_honors_global_skip_signature_verification() { + let payer = Keypair::new(); + let recipient = Pubkey::new_unique(); + let (mempool_tx, mempool_rx) = crossbeam_channel::unbounded(); + let setup = TestSetup::new_with_mempool(SurfpoolFullRpc, mempool_tx); + let recent_blockhash = setup + .context + .svm_locker + .with_svm_reader(|svm_reader| svm_reader.latest_blockhash()); + + // Operator launched with `--skip-signature-verification`; admission must + // honor it even though the per-request `skip_sig_verify` is unset. + setup + .context + .svm_locker + .with_svm_writer(|svm_writer| svm_writer.skip_signature_verification = true); + + let _ = setup + .context + .svm_locker + .0 + .write() + .await + .airdrop(&payer.pubkey(), 2 * LAMPORTS_PER_SOL); + + let tx = + build_transaction_with_invalid_signature(&payer, &recipient, &recent_blockhash); + let tx_encoded = bs58::encode(bincode::serialize(&tx).unwrap()).into_string(); + + // No per-request override: rely solely on the global flag. + let config = SurfpoolRpcSendTransactionConfig { + base: RpcSendTransactionConfig::default(), + skip_sig_verify: None, + }; + + let signature = setup + .rpc + .send_transaction(Some(setup.context.clone()), tx_encoded, Some(config)) + .await + .expect("global skip_signature_verification should bypass admission sigverify"); + assert_eq!(signature, tx.signatures[0].to_string()); + + match mempool_rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("transaction should be enqueued for execution") + { + SimnetCommand::ProcessTransaction(request) => { + assert_eq!(request.transaction.signatures[0], tx.signatures[0]); + } + other => panic!("unexpected simnet command: {other:?}"), + } + } + #[test] fn test_surfpool_rpc_send_transaction_config_json_serialization() { // Test that the config serializes correctly with serde flatten diff --git a/crates/core/src/rpc/jito.rs b/crates/core/src/rpc/jito.rs index fc0066a2b..d8926e8b5 100644 --- a/crates/core/src/rpc/jito.rs +++ b/crates/core/src/rpc/jito.rs @@ -1453,7 +1453,9 @@ mod tests { continue; }; match cmd { - SimnetCommand::ProcessTransaction(_, tx, status_tx, _, _) => { + SimnetCommand::ProcessTransaction(request) => { + let tx = request.transaction; + let status_tx = request.status_tx; observed_process_tx_clone.fetch_add(1, Ordering::SeqCst); // Minimal bookkeeping (mirrors other bundle tests) + unblock the RPC. diff --git a/crates/core/src/surfnet/svm.rs b/crates/core/src/surfnet/svm.rs index e1208b2b2..e172ce3ca 100644 --- a/crates/core/src/surfnet/svm.rs +++ b/crates/core/src/surfnet/svm.rs @@ -4922,6 +4922,7 @@ mod tests { max_profiles: 17, log_bytes_limit: None, skip_blockhash_check: true, + skip_signature_verification: true, }; let (svm, _events_rx, _geyser_rx) = SurfnetSvm::new(config).unwrap(); @@ -4944,6 +4945,7 @@ mod tests { assert!(svm.registered_idls.get(&program_id).unwrap().is_some()); } assert!(svm.skip_blockhash_check); + assert!(svm.skip_signature_verification); } #[test] @@ -4956,6 +4958,7 @@ mod tests { max_profiles: 23, log_bytes_limit: None, skip_blockhash_check: false, + skip_signature_verification: false, }; let (mut svm, _events_rx, _geyser_rx) = SurfnetSvm::new(config).unwrap(); let epoch_info = EpochInfo {