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..ca80e9407 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,9 @@ 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}, }, - types::{SurfnetTransactionStatus, surfpool_tx_metadata_to_litesvm_tx_metadata}, + types::SurfnetTransactionStatus, }; const MAX_PRIORITIZATION_FEE_BLOCKS_CACHE: usize = 150; @@ -64,6 +66,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 +563,7 @@ pub trait Full { meta: Self::Metadata, data: String, config: Option, - ) -> Result; + ) -> BoxFuture>; /// Simulates a transaction without sending it to the network. /// @@ -1566,122 +1606,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( @@ -2257,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 { @@ -2288,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 { @@ -2546,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, }; @@ -2575,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::*; @@ -2615,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(( @@ -2658,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, @@ -2670,7 +2764,7 @@ mod tests { } } - handle + res } #[test_case(None, false ; "when limit is None")] @@ -3008,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")] @@ -3574,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( @@ -3655,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, @@ -3750,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 @@ -4529,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); @@ -4843,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(( @@ -4881,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: {:?}", @@ -4900,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 26c19d25e..d8926e8b5 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; @@ -1451,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/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..e172ce3ca 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; @@ -4846,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(); @@ -4868,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] @@ -4880,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 { 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>),