diff --git a/Cargo.lock b/Cargo.lock index 9f31fbc5..c0e55b5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11342,6 +11342,7 @@ dependencies = [ "solana-keypair", "solana-message 3.0.1", "solana-pubkey 3.0.0", + "solana-signature", "solana-signer", "solana-system-interface 2.0.0", "solana-transaction", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 76a1a262..ebfd5bb4 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -48,6 +48,7 @@ solana-epoch-info = { workspace = true } solana-keypair = { workspace = true } solana-message = { workspace = true } solana-pubkey = { workspace = true } +solana-signature = { workspace = true } solana-signer = { workspace = true } solana-system-interface = { workspace = true } solana-transaction = { workspace = true } diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index d4a739c7..2a77823e 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -25,6 +25,7 @@ use txtx_gql::kit::{helpers::fs::FileLocation, types::frontend::LogLevel}; use crate::{cloud::CloudStartCommand, runbook::handle_execute_runbook_command}; +mod replay; mod simnet; #[derive(Clone)] @@ -127,6 +128,9 @@ enum Command { /// Start MCP server #[clap(name = "mcp", bin_name = "mcp")] Mcp, + /// Replay mainnet transactions locally + #[clap(name = "replay", bin_name = "replay")] + Replay(ReplayCommand), } #[derive(Parser, PartialEq, Clone, Debug)] @@ -273,6 +277,58 @@ pub enum NetworkType { Testnet, } +/// Replay mainnet transactions locally for debugging and analysis. +#[derive(Parser, PartialEq, Clone, Debug)] +pub struct ReplayCommand { + /// Transaction signature(s) to replay + #[arg()] + pub signatures: Vec, + + /// Read transaction signatures from a file (one per line) + #[arg(long = "from-file", short = 'f')] + pub from_file: Option, + + /// RPC URL for fetching transaction data (eg. --rpc-url https://api.mainnet-beta.solana.com) + #[arg( + long = "rpc-url", + short = 'u', + conflicts_with = "network", + env = "SURFPOOL_DATASOURCE_RPC_URL" + )] + pub rpc_url: Option, + + /// Predefined network (mainnet, devnet, testnet) + #[arg(long = "network", short = 'n', value_enum, conflicts_with = "rpc_url")] + pub network: Option, + + /// Enable transaction profiling for detailed execution metrics + #[clap(long = "profile", action = ArgAction::SetTrue, default_value = "false")] + pub profile: bool, + + /// Output results to a JSON file + #[arg(long = "output", short = 'o')] + pub output: Option, + + /// Skip time travel (use current slot instead of transaction's original slot) + #[clap(long = "skip-time-travel", action = ArgAction::SetTrue, default_value = "false")] + pub skip_time_travel: bool, +} + +impl ReplayCommand { + /// Returns the RPC URL to use for fetching transaction data. + pub fn datasource_rpc_url(&self) -> String { + match self.network { + Some(NetworkType::Mainnet) => DEFAULT_MAINNET_RPC_URL.to_string(), + Some(NetworkType::Devnet) => DEFAULT_DEVNET_RPC_URL.to_string(), + Some(NetworkType::Testnet) => DEFAULT_TESTNET_RPC_URL.to_string(), + None => self + .rpc_url + .clone() + .unwrap_or_else(|| DEFAULT_MAINNET_RPC_URL.to_string()), + } + } +} + impl StartSimnet { pub fn get_airdrop_addresses(&self) -> (Vec, Vec) { let mut airdrop_addresses = vec![]; @@ -646,6 +702,9 @@ fn handle_command(opts: Opts, ctx: &Context) -> Result<(), String> { Command::List(cmd) => hiro_system_kit::nestable_block_on(handle_list_command(cmd, ctx)), Command::Cloud(cmd) => hiro_system_kit::nestable_block_on(handle_cloud_commands(cmd)), Command::Mcp => hiro_system_kit::nestable_block_on(handle_mcp_command(ctx)), + Command::Replay(cmd) => { + hiro_system_kit::nestable_block_on(replay::handle_replay_command(cmd, ctx)) + } } } diff --git a/crates/cli/src/cli/replay/mod.rs b/crates/cli/src/cli/replay/mod.rs new file mode 100644 index 00000000..2de7dcaa --- /dev/null +++ b/crates/cli/src/cli/replay/mod.rs @@ -0,0 +1,187 @@ +use std::{env, fs, str::FromStr}; + +use log::info; +use solana_signature::Signature; +use surfpool_core::surfnet::{remote::SurfnetRemoteClient, svm::SurfnetSvm}; +use surfpool_types::{channel, ReplayConfig, ReplayResult, SimnetCommand}; + +use super::{Context, ReplayCommand}; + +/// Environment variable name for transaction signatures (semicolon-separated). +const TX_SIGNATURES_ENV: &str = "TX_SIGNATURES"; + +/// Handles the replay command by fetching and re-executing transactions from mainnet. +/// +/// This is a lightweight standalone handler that initializes an ephemeral SVM +/// without a full RPC server. For interactive use with full surfpool features, +/// use `surfpool start` with the `surfnet_replayTransaction` RPC method instead. +pub async fn handle_replay_command(cmd: ReplayCommand, _ctx: &Context) -> Result<(), String> { + // Step 1: Parse signatures from args, file, or environment variable + let signatures = if let Some(file_path) = &cmd.from_file { + parse_signatures_from_file(file_path)? + } else if !cmd.signatures.is_empty() { + cmd.signatures + .iter() + .map(|s| Signature::from_str(s)) + .collect::, _>>() + .map_err(|e| format!("Invalid signature: {}", e))? + } else if let Ok(env_sigs) = env::var(TX_SIGNATURES_ENV) { + parse_signatures_from_env(&env_sigs)? + } else { + return Err(format!( + "No transaction signatures provided. Use positional args, --from-file, or {} env var", + TX_SIGNATURES_ENV + )); + }; + + if signatures.is_empty() { + return Err("No transaction signatures provided".to_string()); + } + + // Step 2: Determine RPC URL + let rpc_url = cmd.datasource_rpc_url(); + println!("Fetching transactions from: {}", rpc_url); + + // Step 3: Initialize ephemeral SVM (no persistence, no RPC server overhead) + let (surfnet_svm, _simnet_events_rx, _geyser_events_rx) = + SurfnetSvm::new_with_db(None, "replay") + .map_err(|e| format!("Failed to initialize SVM: {}", e))?; + + // Step 4: Create command channel (needed for time travel coordination) + let (simnet_commands_tx, _simnet_commands_rx): ( + channel::Sender, + channel::Receiver, + ) = channel::unbounded(); + + let svm_locker = surfpool_core::surfnet::locker::SurfnetSvmLocker::new(surfnet_svm); + + // Step 5: Create remote client for mainnet data fetching + let remote_client = SurfnetRemoteClient::new(&rpc_url); + + // Step 6: Build replay config from CLI flags + let replay_config = ReplayConfig { + profile: Some(cmd.profile), + time_travel: Some(!cmd.skip_time_travel), + }; + + // Step 7: Replay each transaction + let mut results: Vec = Vec::new(); + let total = signatures.len(); + + for (i, signature) in signatures.iter().enumerate() { + println!( + "\n[{}/{}] Replaying: {}", + i + 1, + total, + signature + ); + + match svm_locker + .replay_transaction( + &remote_client, + *signature, + replay_config.clone(), + simnet_commands_tx.clone(), + ) + .await + { + Ok(result) => { + print_replay_result(&result); + results.push(result); + } + Err(e) => { + eprintln!(" Error: {}", e); + // Continue with other transactions + } + } + } + + // Step 8: Output results to file if requested + if let Some(output_path) = &cmd.output { + let json = serde_json::to_string_pretty(&results) + .map_err(|e| format!("Failed to serialize results: {}", e))?; + fs::write(output_path, json) + .map_err(|e| format!("Failed to write output file: {}", e))?; + info!("Results written to {}", output_path); + println!("\nResults written to: {}", output_path); + } + + // Summary + let successful = results.iter().filter(|r| r.success).count(); + let failed = results.len() - successful; + println!( + "\n=== Summary ===\nTotal: {} | Success: {} | Failed: {}", + total, successful, failed + ); + + Ok(()) +} + +/// Parses transaction signatures from a file (one per line). +fn parse_signatures_from_file(file_path: &str) -> Result, String> { + let content = + fs::read_to_string(file_path).map_err(|e| format!("Failed to read file: {}", e))?; + + content + .lines() + .filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#')) + .map(|line| { + Signature::from_str(line.trim()) + .map_err(|e| format!("Invalid signature '{}': {}", line.trim(), e)) + }) + .collect() +} + +/// Parses transaction signatures from an environment variable. +/// Supports both comma and semicolon as delimiters. +fn parse_signatures_from_env(env_value: &str) -> Result, String> { + // Support both comma (shell-friendly) and semicolon as delimiters + let delimiter = if env_value.contains(',') { ',' } else { ';' }; + env_value + .split(delimiter) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + Signature::from_str(s).map_err(|e| format!("Invalid signature '{}': {}", s, e)) + }) + .collect() +} + +/// Prints a human-readable summary of a replay result. +fn print_replay_result(result: &ReplayResult) { + println!("\n--- Transaction {} ---", result.signature); + println!( + "Status: {}", + if result.success { "SUCCESS" } else { "FAILED" } + ); + println!( + "Original slot: {} | Replay slot: {}", + result.original_slot, result.replay_slot + ); + + if let Some(block_time) = result.original_block_time { + if let Some(dt) = chrono::DateTime::from_timestamp(block_time, 0) { + println!("Original time: {}", dt.format("%Y-%m-%d %H:%M:%S UTC")); + } + } + + println!("Compute units: {}", result.compute_units_consumed); + + if let Some(ref error) = result.error { + println!("Error: {}", error); + } + + if !result.logs.is_empty() { + println!("Logs ({}):", result.logs.len()); + for log in result.logs.iter().take(10) { + println!(" {}", log); + } + if result.logs.len() > 10 { + println!(" ... and {} more", result.logs.len() - 10); + } + } + + if let Some(ref warning) = result.state_warning { + println!("Warning: {}", warning); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 60de4047..908da0b9 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -449,6 +449,38 @@ impl SurfpoolError { error.message = format!("Expected profile not found for key {key}"); Self(error) } + + pub fn replay_requires_remote() -> Self { + let mut error = Error::internal_error(); + error.message = "Transaction replay requires a remote RPC connection".to_string(); + error.data = Some(json!( + "Start surfpool with --rpc-url or --network to enable replay" + )); + Self(error) + } + + pub fn replay_transaction_decode(signature: S, e: E) -> Self + where + S: Display, + E: Display, + { + let mut error = Error::internal_error(); + error.message = format!("Failed to decode transaction {signature}"); + error.data = Some(json!(e.to_string())); + Self(error) + } + + pub fn replay_unsupported_encoding(signature: S, encoding: &str) -> Self + where + S: Display, + { + let mut error = Error::internal_error(); + error.message = format!("Unsupported transaction encoding for replay: {encoding}"); + error.data = Some(json!(format!( + "Transaction {signature} uses unsupported encoding. Expected base64." + ))); + Self(error) + } } impl From for SurfpoolError { diff --git a/crates/core/src/rpc/surfnet_cheatcodes.rs b/crates/core/src/rpc/surfnet_cheatcodes.rs index a093a219..341cec62 100644 --- a/crates/core/src/rpc/surfnet_cheatcodes.rs +++ b/crates/core/src/rpc/surfnet_cheatcodes.rs @@ -268,6 +268,35 @@ pub trait SurfnetCheatcodes { config: Option, ) -> BoxFuture>>; + /// Replays a mainnet transaction locally by signature. + /// + /// Fetches the transaction from a remote RPC, retrieves the required account states, + /// time-travels to the original slot, and executes it locally. + /// + /// ## Parameters + /// - `signature`: The transaction signature to replay. + /// - `config`: Optional replay configuration (profile, time_travel). + /// + /// ## Returns + /// A `RpcResponse` containing the replay results. + /// + /// ## Example Request + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "id": 1, + /// "method": "surfnet_replayTransaction", + /// "params": ["5N7Lw...", {"profile": true, "timeTravel": true}] + /// } + /// ``` + #[rpc(meta, name = "surfnet_replayTransaction")] + fn replay_transaction( + &self, + meta: Self::Metadata, + signature: String, + config: Option, + ) -> BoxFuture>>; + /// Retrieves all profiling results for a given tag. /// /// ## Parameters @@ -1395,6 +1424,49 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc { }) } + fn replay_transaction( + &self, + meta: Self::Metadata, + signature_str: String, + config: Option, + ) -> BoxFuture>> { + use crate::rpc::utils::verify_signature; + + // Validate signature + let signature = match verify_signature(&signature_str) { + Ok(sig) => sig, + Err(e) => return Box::pin(future::err(e)), + }; + + Box::pin(async move { + let SurfnetRpcContext { + svm_locker, + remote_ctx, + } = meta.get_rpc_context(CommitmentConfig::confirmed())?; + + // Require remote client for replay + let remote_client = remote_ctx + .as_ref() + .map(|(client, _)| client.clone()) + .ok_or_else(SurfpoolError::replay_requires_remote)?; + + let simnet_command_tx = meta.get_surfnet_command_tx()?; + + let config = config.unwrap_or_default(); + + let result = svm_locker + .replay_transaction(&remote_client, signature, config, simnet_command_tx) + .await?; + + let slot = svm_locker.get_latest_absolute_slot(); + + Ok(RpcResponse { + context: RpcResponseContext::new(slot), + value: result, + }) + }) + } + fn get_profile_results_by_tag( &self, meta: Self::Metadata, diff --git a/crates/core/src/rpc/utils.rs b/crates/core/src/rpc/utils.rs index 96a0a22b..a4df91d8 100644 --- a/crates/core/src/rpc/utils.rs +++ b/crates/core/src/rpc/utils.rs @@ -77,7 +77,7 @@ fn verify_hash(input: &str) -> Result { .map_err(|e| Error::invalid_params(format!("Invalid param: {e:?}"))) } -fn verify_signature(input: &str) -> Result { +pub fn verify_signature(input: &str) -> Result { input .parse() .map_err(|e| Error::invalid_params(format!("Invalid param: {e:?}"))) diff --git a/crates/core/src/surfnet/locker.rs b/crates/core/src/surfnet/locker.rs index affd2e5c..7d6dcba1 100644 --- a/crates/core/src/surfnet/locker.rs +++ b/crates/core/src/surfnet/locker.rs @@ -1084,6 +1084,202 @@ impl SurfnetSvmLocker { Ok(self.with_contextualized_svm_reader(|_| uuid)) } + /// Replays a transaction by signature from a remote RPC. + /// + /// Fetches the transaction, its accounts, time-travels to the original slot, + /// and executes it locally with optional profiling. + pub async fn replay_transaction( + &self, + remote_client: &SurfnetRemoteClient, + signature: Signature, + config: surfpool_types::ReplayConfig, + simnet_command_tx: Sender, + ) -> SurfpoolResult { + use base64::Engine; + use solana_transaction_status::{EncodedTransaction, TransactionBinaryEncoding}; + + // Step 1: Fetch the transaction from remote RPC + // Use u64::MAX as slot hint to avoid underflow in confirmation calculation + // when replaying mainnet transactions on a local SVM that starts at slot 0. + // The actual slot will be extracted from the transaction metadata. + let tx_config = RpcTransactionConfig { + encoding: Some(UiTransactionEncoding::Base64), + commitment: Some(CommitmentConfig::finalized()), + max_supported_transaction_version: Some(0), + }; + + let tx_result = remote_client + .get_transaction(signature, tx_config, u64::MAX) + .await; + + let encoded_tx = match tx_result { + GetTransactionResult::FoundTransaction(_, tx, _) => tx, + GetTransactionResult::None(sig) => { + return Err(SurfpoolError::transaction_not_found(sig)); + } + }; + + // Step 2: Extract slot and block time + let original_slot = encoded_tx.slot; + let original_block_time = encoded_tx.block_time; + + // Step 3: Decode the transaction from base64 + let versioned_tx: VersionedTransaction = match &encoded_tx.transaction.transaction { + EncodedTransaction::Binary(data, TransactionBinaryEncoding::Base64) => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| SurfpoolError::replay_transaction_decode(&signature, e))?; + bincode::deserialize(&bytes) + .map_err(|e| SurfpoolError::replay_transaction_decode(&signature, e))? + } + EncodedTransaction::Binary(_, TransactionBinaryEncoding::Base58) => { + return Err(SurfpoolError::replay_unsupported_encoding( + &signature, + "base58", + )); + } + _ => { + return Err(SurfpoolError::replay_unsupported_encoding( + &signature, + "json/accounts", + )); + } + }; + + // Step 4: Create remote context for account fetching + let remote_ctx = Some((remote_client.clone(), CommitmentConfig::finalized())); + + // Step 5: Extract all accounts including ALT lookups + let tx_loaded_addresses = self + .get_loaded_addresses(&remote_ctx, &versioned_tx.message) + .await?; + + let all_accounts = self + .get_pubkeys_from_message( + &versioned_tx.message, + tx_loaded_addresses + .as_ref() + .map(|l| l.all_loaded_addresses()), + ) + .clone(); + + // Step 6: Fetch account states (current state - historical not available) + let state_warning = Some( + "Account states are fetched at current slot, not at original transaction slot. \ + Results may differ from original execution due to state changes." + .to_string(), + ); + + let account_updates = self + .get_multiple_accounts(&remote_ctx, &all_accounts, None) + .await? + .inner; + + // Also fetch ALT accounts + let alt_account_updates = self + .get_multiple_accounts( + &remote_ctx, + &tx_loaded_addresses + .as_ref() + .map(|l| l.alt_addresses()) + .unwrap_or_default(), + None, + ) + .await? + .inner; + + // Step 7: Write accounts to SVM + self.with_svm_writer(|svm_writer| { + for update in &account_updates { + svm_writer.write_account_update(update.clone()); + } + for update in &alt_account_updates { + svm_writer.write_account_update(update.clone()); + } + }); + + // Step 8: Replace blockhash with local valid blockhash + // The original blockhash is from mainnet and won't be recognized locally. + // We preserve the transaction's instructions and accounts but use a local blockhash. + let local_blockhash = self.with_svm_reader(|svm| svm.latest_blockhash()); + let versioned_tx = replace_transaction_blockhash(versioned_tx, local_blockhash); + + // Step 9: Time travel to transaction's slot (if enabled) + let replay_slot = if config.should_time_travel() { + let time_travel_config = TimeTravelConfig::AbsoluteSlot(original_slot); + match self.time_travel(None, simnet_command_tx.clone(), time_travel_config) { + Ok(epoch_info) => epoch_info.absolute_slot, + Err(_) => self.get_latest_absolute_slot(), + } + } else { + self.get_latest_absolute_slot() + }; + + // Step 9: Execute the transaction + let accounts_used: Vec = all_accounts.iter().map(|p| p.to_string()).collect(); + + // Both profiling and non-profiling paths use sigverify=false for replay + // since we replaced the blockhash which invalidates the original signatures + let svm_clone = self.with_svm_reader(|svm_reader| svm_reader.clone_for_profiling()); + let svm_locker = SurfnetSvmLocker::new(svm_clone); + + let (status_tx, _) = crossbeam_channel::unbounded(); + let skip_preflight = true; + let sigverify = false; // Skip signature verification for replay (blockhash was replaced) + let do_propagate = false; + + let mut keyed_profile_result = svm_locker + .fetch_all_tx_accounts_then_process_tx_returning_profile_res( + &remote_ctx, + versioned_tx, + status_tx, + skip_preflight, + sigverify, + do_propagate, + ) + .await?; + + // Extract results from profile + let success = keyed_profile_result.transaction_profile.error_message.is_none(); + let error = keyed_profile_result.transaction_profile.error_message.clone(); + let logs = keyed_profile_result + .transaction_profile + .log_messages + .clone() + .unwrap_or_default(); + let cus = keyed_profile_result.transaction_profile.compute_units_consumed; + + // Store profile result if profiling was requested + let profile_result = if config.should_profile() { + let uuid = Uuid::new_v4(); + keyed_profile_result.key = UuidOrSignature::Uuid(uuid); + self.with_svm_writer(|svm_writer| { + svm_writer.write_simulated_profile_result(uuid, Some("replay".to_string()), keyed_profile_result.clone()) + })?; + let ui_result = self.get_profile_result( + UuidOrSignature::Uuid(uuid), + &RpcProfileResultConfig::default(), + )?; + ui_result + } else { + None + }; + + Ok(surfpool_types::ReplayResult { + signature: signature.to_string(), + original_slot, + replay_slot, + original_block_time, + success, + logs, + compute_units_consumed: cus, + error, + profile_result, + accounts_used, + state_warning, + }) + } + async fn fetch_all_tx_accounts_then_process_tx_returning_profile_res( &self, remote_ctx: &Option<(SurfnetRemoteClient, CommitmentConfig)>, @@ -3657,6 +3853,26 @@ pub fn format_ui_amount(amount: u64, decimals: u8) -> f64 { } } +/// Replaces the recent blockhash in a VersionedTransaction. +/// Used for replay to substitute mainnet blockhash with a local valid one. +fn replace_transaction_blockhash(tx: VersionedTransaction, new_blockhash: Hash) -> VersionedTransaction { + let new_message = match tx.message { + VersionedMessage::Legacy(mut msg) => { + msg.recent_blockhash = new_blockhash; + VersionedMessage::Legacy(msg) + } + VersionedMessage::V0(mut msg) => { + msg.recent_blockhash = new_blockhash; + VersionedMessage::V0(msg) + } + }; + + VersionedTransaction { + signatures: tx.signatures, + message: new_message, + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/crates/types/src/types.rs b/crates/types/src/types.rs index 7cdc3f8a..0fdbc23f 100644 --- a/crates/types/src/types.rs +++ b/crates/types/src/types.rs @@ -1257,6 +1257,56 @@ impl RunbookExecutionStatusReport { } } +/// Configuration for transaction replay. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ReplayConfig { + /// Enable transaction profiling to capture detailed execution metrics. + #[serde(default)] + pub profile: Option, + /// Time travel to the transaction's original slot before execution. + #[serde(default)] + pub time_travel: Option, +} + +impl ReplayConfig { + pub fn should_profile(&self) -> bool { + self.profile.unwrap_or(true) + } + + pub fn should_time_travel(&self) -> bool { + self.time_travel.unwrap_or(true) + } +} + +/// Result of replaying a transaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayResult { + /// The signature of the replayed transaction. + pub signature: String, + /// The slot at which the transaction was originally executed. + pub original_slot: Slot, + /// The slot at which the transaction was replayed. + pub replay_slot: Slot, + /// Block time of the original transaction (unix timestamp). + pub original_block_time: Option, + /// Whether the replay execution succeeded. + pub success: bool, + /// Execution log messages. + pub logs: Vec, + /// Compute units consumed during execution. + pub compute_units_consumed: u64, + /// Error message if execution failed. + pub error: Option, + /// Detailed profile result (if profiling was enabled). + pub profile_result: Option, + /// Accounts used in the transaction. + pub accounts_used: Vec, + /// Warning about state differences (historical vs current). + pub state_warning: Option, +} + #[cfg(test)] mod tests { use serde_json::json;