Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
59 changes: 59 additions & 0 deletions crates/cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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")]
Comment on lines +131 to +132
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Replay mainnet transactions locally
#[clap(name = "replay", bin_name = "replay")]
#[clap(
name = "replay",
bin_name = "replay",
about = "Re-execute transactions from the remote against a local surfnet",
long_about = "Re-executes transactions from the remote against a local surfnet. \nNote: Different account states at execution time can lead to completely different transaction results. While replay can be helpful in transaction debugging and introspection, it is not meant to exactly replicate the remote transaction."
)]

Replay(ReplayCommand),
}

#[derive(Parser, PartialEq, Clone, Debug)]
Expand Down Expand Up @@ -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<String>,

/// Read transaction signatures from a file (one per line)
#[arg(long = "from-file", short = 'f')]
pub from_file: Option<String>,

/// 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<String>,

/// Predefined network (mainnet, devnet, testnet)
#[arg(long = "network", short = 'n', value_enum, conflicts_with = "rpc_url")]
pub network: Option<NetworkType>,

/// 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<String>,

/// 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<Pubkey>, Vec<SimnetEvent>) {
let mut airdrop_addresses = vec![];
Expand Down Expand Up @@ -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))
}
}
}

Expand Down
166 changes: 166 additions & 0 deletions crates/cli/src/cli/replay/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
use std::{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};

/// 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 or file
let signatures = if let Some(file_path) = &cmd.from_file {
parse_signatures_from_file(file_path)?
} else if cmd.signatures.is_empty() {
return Err(
"No transaction signatures provided. Use positional args or --from-file".to_string(),
);
} else {
cmd.signatures
.iter()
.map(|s| Signature::from_str(s))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Invalid signature: {}", e))?
};

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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SurfnetSvm::default() is a cleaner interface for this!

.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<SimnetCommand>,
channel::Receiver<SimnetCommand>,
) = 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<ReplayResult> = 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<Vec<Signature>, 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()
}

/// 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" }
);
Comment on lines +153 to +156
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get some green/red color on success/fail to make it easy for the user to see success status?

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);
}
}
32 changes: 32 additions & 0 deletions crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<S, E>(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<S>(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<StorageError> for SurfpoolError {
Expand Down
72 changes: 72 additions & 0 deletions crates/core/src/rpc/surfnet_cheatcodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,35 @@ pub trait SurfnetCheatcodes {
config: Option<RpcProfileResultConfig>,
) -> BoxFuture<Result<RpcResponse<UiKeyedProfileResult>>>;

/// 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<ReplayResult>` 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<surfpool_types::ReplayConfig>,
Copy link
Collaborator

@lgalabru lgalabru Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command

surfpool replay <tx-sig>

is terse and feels really cool.

Regarding the underlying cheatcode, I think being able to execute any transaction (including some that have not been broadcasted to mainnet) could be more useful (the CLI can still accept a tx-sig, fetch the transaction first and pass the transaction bytes).

I'm not incredibly excited by the idea of introducing a 4th entrypoint executing transaction, what's our take on augmenting surfnet_profileTransaction with a time_travel_config attribute?
Devs will most likely be playing / replaying to get all the data you can get out of the execution, which is what surfnet_profileTransaction gives you.

) -> BoxFuture<Result<RpcResponse<surfpool_types::ReplayResult>>>;

/// Retrieves all profiling results for a given tag.
///
/// ## Parameters
Expand Down Expand Up @@ -1395,6 +1424,49 @@ impl SurfnetCheatcodes for SurfnetCheatcodesRpc {
})
}

fn replay_transaction(
&self,
meta: Self::Metadata,
signature_str: String,
config: Option<surfpool_types::ReplayConfig>,
) -> BoxFuture<Result<RpcResponse<surfpool_types::ReplayResult>>> {
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,
Expand Down
Loading