Skip to content
Open
Show file tree
Hide file tree
Changes from all 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")]
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
187 changes: 187 additions & 0 deletions crates/cli/src/cli/replay/mod.rs
Original file line number Diff line number Diff line change
@@ -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::<Result<Vec<_>, _>>()
.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<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()
}

/// Parses transaction signatures from an environment variable.
/// Supports both comma and semicolon as delimiters.
fn parse_signatures_from_env(env_value: &str) -> Result<Vec<Signature>, 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);
}
}
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
Loading