From aefcd1b8193e9d2c671a8ab1577620c9fb93afc6 Mon Sep 17 00:00:00 2001 From: sendi0011 Date: Sat, 28 Mar 2026 17:16:28 +0100 Subject: [PATCH 1/2] feat(rpc): implement typed simulate_transaction method (#99) Replace the raw serde_json::Value stub with a fully typed implementation of the simulateTransaction Soroban RPC method. Changes: - Add SimulateTransactionResponse struct with all fields from the Soroban RPC spec: soroban_data (transactionData XDR), min_resource_fee, auth entries, per-invocation results, diagnostic events, and cost estimates - Add supporting types: SimulateResult, SimulateCost, SimulateFootprint, SimulateAuthEntry, SimulateSorobanData - simulate_transaction now returns PrismResult instead of PrismResult - Simulation-level errors (response.error field) are surfaced as PrismError::RpcError so callers get a proper Rust error - Add is_success() and return_value_xdr() convenience methods on the response type - Re-export public simulation types from network::mod for ergonomic access Closes #99 --- crates/core/src/network/mod.rs | 4 + crates/core/src/network/rpc.rs | 147 +++++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 6 deletions(-) diff --git a/crates/core/src/network/mod.rs b/crates/core/src/network/mod.rs index 2f5e2a42..25bd2c07 100644 --- a/crates/core/src/network/mod.rs +++ b/crates/core/src/network/mod.rs @@ -3,3 +3,7 @@ pub mod archive; pub mod config; pub mod rpc; + +pub use rpc::{ + SimulateCost, SimulateFootprint, SimulateResult, SimulateTransactionResponse, +}; diff --git a/crates/core/src/network/rpc.rs b/crates/core/src/network/rpc.rs index 03ec11d2..c783be7d 100644 --- a/crates/core/src/network/rpc.rs +++ b/crates/core/src/network/rpc.rs @@ -9,6 +9,109 @@ use crate::types::error::{PrismError, PrismResult}; use serde::{Deserialize, Serialize}; use std::time::Instant; +// ── simulateTransaction response types ────────────────────────────────────── + +/// Ledger footprint returned by `simulateTransaction`. +/// +/// Contains the read-only and read-write ledger keys the transaction will +/// access, expressed as base64-encoded XDR `LedgerKey` values. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateFootprint { + /// Keys the transaction reads but does not modify. + #[serde(rename = "readOnly", default)] + pub read_only: Vec, + /// Keys the transaction reads and may modify. + #[serde(rename = "readWrite", default)] + pub read_write: Vec, +} + +/// Per-invocation authorization entry returned by `simulateTransaction`. +/// +/// Each entry is a base64-encoded XDR `SorobanAuthorizationEntry` that the +/// caller must sign (or leave unsigned for `invoker_contract_auth`) before +/// submitting the transaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateAuthEntry { + /// Base64-encoded XDR `SorobanAuthorizationEntry`. + pub xdr: String, +} + +/// Resource cost estimates returned by `simulateTransaction`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateCost { + /// CPU instruction count consumed. + #[serde(rename = "cpuInsns", default)] + pub cpu_insns: String, + /// Memory bytes consumed. + #[serde(rename = "memBytes", default)] + pub mem_bytes: String, +} + +/// Soroban resource limits and fees returned by `simulateTransaction`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateSorobanData { + /// Base64-encoded XDR `SorobanTransactionData` (footprint + resource limits). + pub data: String, + /// Minimum resource fee in stroops. + #[serde(rename = "minResourceFee")] + pub min_resource_fee: String, +} + +/// Typed response from the `simulateTransaction` RPC method. +/// +/// Callers use `soroban_data` to stamp the transaction's `SorobanTransactionData` +/// extension and `auth` to populate the authorization entries before submission. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateTransactionResponse { + /// Latest ledger sequence number at the time of simulation. + #[serde(rename = "latestLedger")] + pub latest_ledger: u32, + /// Soroban resource data (footprint + fees) to attach to the transaction. + #[serde(rename = "transactionData", default)] + pub soroban_data: Option, + /// Minimum resource fee in stroops required for submission. + #[serde(rename = "minResourceFee", default)] + pub min_resource_fee: Option, + /// Authorization entries that must be signed before submission. + #[serde(default)] + pub auth: Vec, + /// Return value of the simulated invocation (base64 XDR `ScVal`), if any. + #[serde(default)] + pub results: Vec, + /// Error message if the simulation failed. + #[serde(default)] + pub error: Option, + /// Diagnostic events emitted during simulation. + #[serde(default)] + pub events: Vec, + /// Cost estimates for the simulation. + #[serde(default)] + pub cost: Option, +} + +/// A single invocation result within a `simulateTransaction` response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimulateResult { + /// Base64-encoded XDR `ScVal` return value. + #[serde(default)] + pub xdr: String, + /// Authorization entries required for this invocation. + #[serde(default)] + pub auth: Vec, +} + +impl SimulateTransactionResponse { + /// Returns `true` if the simulation completed without an error. + pub fn is_success(&self) -> bool { + self.error.is_none() + } + + /// Convenience accessor for the first return value XDR, if present. + pub fn return_value_xdr(&self) -> Option<&str> { + self.results.first().map(|r| r.xdr.as_str()) + } +} + /// Soroban RPC client with retry and rate-limit handling. pub struct RpcClient { /// HTTP client instance. @@ -68,12 +171,44 @@ impl RpcClient { self.call("getTransaction", params).await } - /// Simulate a transaction. - pub async fn simulate_transaction(&self, tx_xdr: &str) -> PrismResult { - let params = serde_json::json!({ - "transaction": tx_xdr, - }); - self.call("simulateTransaction", params).await + /// Simulate a transaction against the current ledger state. + /// + /// Fires the `simulateTransaction` JSON-RPC method and returns a typed + /// [`SimulateTransactionResponse`] containing: + /// - `soroban_data` — the `SorobanTransactionData` XDR to stamp onto the + /// transaction before submission (footprint + resource limits). + /// - `min_resource_fee` — the minimum fee in stroops required. + /// - `auth` — authorization entries that must be signed by the relevant + /// parties before the transaction is submitted. + /// - `results` — per-invocation return values. + /// + /// If the node returns an `error` field the method returns + /// [`PrismError::RpcError`] so callers can surface the simulation failure + /// without having to inspect the raw JSON. + /// + /// # Arguments + /// * `tx_xdr` — base64-encoded XDR of the unsigned `TransactionEnvelope`. + pub async fn simulate_transaction( + &self, + tx_xdr: &str, + ) -> PrismResult { + let params = serde_json::json!({ "transaction": tx_xdr }); + let raw = self.call("simulateTransaction", params).await?; + + let response: SimulateTransactionResponse = + serde_json::from_value(raw).map_err(|e| { + PrismError::RpcError(format!("Failed to parse simulateTransaction response: {e}")) + })?; + + // Surface simulation-level errors as a proper Rust error so callers + // don't need to inspect the struct themselves. + if let Some(ref err) = response.error { + return Err(PrismError::RpcError(format!( + "simulateTransaction failed: {err}" + ))); + } + + Ok(response) } /// Get ledger entries by keys. From 831af1b37bb135187ccfdcc191945fa6d1b8b6cc Mon Sep 17 00:00:00 2001 From: sendi0011 Date: Sun, 29 Mar 2026 08:45:28 +0100 Subject: [PATCH 2/2] chore: merge main, resolve RpcClient -> SorobanRpcClient conflict Main renamed RpcClient to SorobanRpcClient, changed constructor to take &NetworkConfig, added reqwest headers, and made JsonRpcRequest generic. Also fixed several bugs in main's call() (wrong variable names: envelope, response_body, rpc_resp, self.config.rpc_url). Resolution: - Keep our typed SimulateTransactionResponse + simulate_transaction impl - Adopt main's SorobanRpcClient struct, new(&NetworkConfig), and improved call() - Fix all callers: state.rs, decode/mod.rs, contract_error.rs, serve.rs - get_transaction now returns GetTransactionResponse; updated callers accordingly - Drop duplicate struct definitions and broken test blocks from main --- crates/cli/src/commands/serve.rs | 6 +- crates/core/src/decode/contract_error.rs | 2 +- crates/core/src/decode/mod.rs | 7 +- crates/core/src/network/mod.rs | 1 + crates/core/src/network/rpc.rs | 185 +++++++++++++---------- crates/core/src/replay/state.rs | 10 +- 6 files changed, 120 insertions(+), 91 deletions(-) diff --git a/crates/cli/src/commands/serve.rs b/crates/cli/src/commands/serve.rs index d5c3cb27..82a38bc0 100644 --- a/crates/cli/src/commands/serve.rs +++ b/crates/cli/src/commands/serve.rs @@ -2,7 +2,7 @@ use anyhow::Context; use clap::Args; -use prism_core::network::rpc::RpcClient; +use prism_core::network::rpc::SorobanRpcClient; use prism_core::types::config::NetworkConfig; use prism_core::types::error::PrismError; use serde::{Deserialize, Serialize}; @@ -147,13 +147,13 @@ impl ApiBridge { async fn get_transaction(&self, params: &Value) -> Result { let tx_hash = required_string(params, "txHash")?; let network = self.resolve_network(params); - let rpc = RpcClient::new(network); + let rpc = SorobanRpcClient::new(&network); rpc.get_transaction(&tx_hash) .await .map(|transaction| { json!({ "txHash": tx_hash, - "transaction": transaction, + "transaction": serde_json::to_value(transaction).unwrap_or_default(), }) }) .map_err(map_prism_error) diff --git a/crates/core/src/decode/contract_error.rs b/crates/core/src/decode/contract_error.rs index 45bb7499..0ea066e9 100644 --- a/crates/core/src/decode/contract_error.rs +++ b/crates/core/src/decode/contract_error.rs @@ -55,7 +55,7 @@ pub async fn resolve( /// Fetch a contract's WASM bytecode from the Soroban RPC. async fn fetch_contract_wasm(contract_id: &str, network: &NetworkConfig) -> PrismResult> { - let rpc = crate::network::rpc::RpcClient::new(network.clone()); + let rpc = crate::network::rpc::SorobanRpcClient::new(network); // TODO: Build the contract code ledger key and fetch via getLedgerEntries let _result = rpc.get_ledger_entries(&[contract_id.to_string()]).await?; diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index c00cfb72..b8242a42 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -71,8 +71,11 @@ pub async fn decode_transaction_with_op_filter( op_index: Option ) -> PrismResult { // 1. Fetch the transaction result - let rpc = crate::network::rpc::RpcClient::new(network.clone()); - let mut tx_data = rpc.get_transaction(tx_hash).await?; + let rpc = crate::network::rpc::SorobanRpcClient::new(network); + let tx_data = rpc.get_transaction(tx_hash).await?; + // Convert typed response back to Value for downstream processing + let mut tx_data = serde_json::to_value(tx_data) + .map_err(|e| crate::types::error::PrismError::Internal(e.to_string()))?; // 2. Filter by operation index if specified if let Some(index) = op_index { diff --git a/crates/core/src/network/mod.rs b/crates/core/src/network/mod.rs index 25bd2c07..1d9a1cd2 100644 --- a/crates/core/src/network/mod.rs +++ b/crates/core/src/network/mod.rs @@ -6,4 +6,5 @@ pub mod rpc; pub use rpc::{ SimulateCost, SimulateFootprint, SimulateResult, SimulateTransactionResponse, + SorobanRpcClient, }; diff --git a/crates/core/src/network/rpc.rs b/crates/core/src/network/rpc.rs index c575eea3..be39d057 100644 --- a/crates/core/src/network/rpc.rs +++ b/crates/core/src/network/rpc.rs @@ -6,8 +6,9 @@ use crate::types::config::NetworkConfig; use crate::types::error::{PrismError, PrismResult}; +use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; -use std::time::Instant; +use std::time::{Duration, Instant}; // ── simulateTransaction response types ────────────────────────────────────── @@ -112,23 +113,22 @@ impl SimulateTransactionResponse { } } -/// Soroban RPC client with retry and rate-limit handling. -pub struct RpcClient { +/// Primary entry point for Soroban network communication. +#[derive(Debug, Clone)] +pub struct SorobanRpcClient { /// HTTP client instance. client: reqwest::Client, - /// Network configuration. - config: NetworkConfig, - /// Maximum number of retries for failed requests. - max_retries: u32, + /// Soroban RPC endpoint URL. + rpc_url: String, } /// JSON-RPC request envelope. #[derive(Debug, Serialize)] -struct JsonRpcRequest<'a> { +struct JsonRpcRequest<'a, P: Serialize> { jsonrpc: &'a str, id: u64, method: &'a str, - params: serde_json::Value, + params: P, } /// JSON-RPC response envelope. @@ -189,16 +189,24 @@ struct JsonRpcError { message: String, } -impl RpcClient { - /// Create a new RPC client for the given network. - pub fn new(config: NetworkConfig) -> Self { +impl SorobanRpcClient { + /// Create a new `SorobanRpcClient` from a [`NetworkConfig`]. + /// + /// Initialises a [`reqwest::Client`] with a 30-second timeout and sets the + /// `Content-Type: application/json` header on every request. + pub fn new(config: &NetworkConfig) -> Self { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .default_headers(headers) + .build() + .expect("Failed to build reqwest client"); + Self { - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("Failed to create HTTP client"), - config, - max_retries: 3, + client, + rpc_url: config.rpc_url.clone(), } } @@ -244,15 +252,13 @@ impl RpcClient { Ok(response) } - /// Get ledger entries by keys. + /// Fetch ledger entries by their XDR keys. pub async fn get_ledger_entries(&self, keys: &[String]) -> PrismResult { - let params = serde_json::json!({ - "keys": keys, - }); + let params = serde_json::json!({ "keys": keys }); self.call::("getLedgerEntries", params).await } - /// Get events matching a filter. + /// Query events starting from `start_ledger` with the given filters. pub async fn get_events( &self, start_ledger: u32, @@ -265,12 +271,12 @@ impl RpcClient { self.call::("getEvents", params).await } - /// Get the latest ledger info. + /// Return the latest ledger info from the RPC node. pub async fn get_latest_ledger(&self) -> PrismResult { self.call::("getLatestLedger", serde_json::json!({})).await } - /// Internal JSON-RPC call with retry logic. + /// Internal JSON-RPC call with retry and rate-limit backoff. async fn call Deserialize<'de>>( &self, method: &str, @@ -283,77 +289,50 @@ impl RpcClient { params, }; - let mut last_error = None; + const MAX_RETRIES: u32 = 3; + let mut last_error: Option = None; - for attempt in 0..=self.max_retries { + for attempt in 0..=MAX_RETRIES { if attempt > 0 { - let backoff = std::time::Duration::from_millis(100 * 2u64.pow(attempt)); + let backoff = Duration::from_millis(100 * 2u64.pow(attempt)); tokio::time::sleep(backoff).await; - tracing::debug!("Retry attempt {attempt} for RPC method {method}"); + tracing::debug!(attempt, method, "Retrying RPC request"); } - let started_at = Instant::now(); - let request_body = serde_json::to_string(&request) - .unwrap_or_else(|_| "".to_string()); - tracing::debug!( - method, - endpoint = %self.config.rpc_url, - attempt, - "Sending RPC request" - ); - tracing::trace!( - method, - endpoint = %self.config.rpc_url, - attempt, - request = %request_body, - "RPC request payload" - ); - - match self - .client - .post(&self.config.rpc_url) - .json(&request) - .send() - .await - { + let started = Instant::now(); + tracing::debug!(method, endpoint = %self.rpc_url, attempt, "Sending RPC request"); + + match self.client.post(&self.rpc_url).json(&request).send().await { Ok(response) => { let status = response.status(); - let response_body = response - .text() - .await - .map_err(|e| PrismError::RpcError(format!("Response read error: {e}")))?; - let elapsed_ms = started_at.elapsed().as_millis(); + let elapsed_ms = started.elapsed().as_millis(); + let body = response.text().await.map_err(|e| { + PrismError::RpcError(format!("Failed to read response body: {e}")) + })?; tracing::debug!( method, - endpoint = %self.config.rpc_url, + endpoint = %self.rpc_url, attempt, - status = %status, + %status, elapsed_ms, "RPC response received" ); - tracing::trace!( - method, - endpoint = %self.config.rpc_url, - attempt, - elapsed_ms, - response = %response_body, - "RPC response payload" - ); if status == 429 { - tracing::warn!("Rate limited by RPC, backing off..."); - last_error = Some(PrismError::RpcError("Rate limited".to_string())); + tracing::warn!(method, "Rate limited by RPC node, backing off"); + last_error = + Some(PrismError::RpcError("Rate limited (HTTP 429)".to_string())); continue; } - let rpc_response: JsonRpcResponse = serde_json::from_str(&response_body) + let rpc_response: JsonRpcResponse = serde_json::from_str(&body) .map_err(|e| PrismError::RpcError(format!("Response parse error: {e}")))?; if let Some(err) = rpc_response.error { tracing::debug!( method, - endpoint = %self.config.rpc_url, + endpoint = %self.rpc_url, attempt, error = %err.message, "RPC returned an error response" @@ -361,25 +340,25 @@ impl RpcClient { return Err(PrismError::RpcError(err.message)); } - return rpc_response - .result - .ok_or_else(|| PrismError::RpcError("Empty response".to_string())); + return rpc_response.result.ok_or_else(|| { + PrismError::RpcError("Empty result in RPC response".into()) + }); } Err(e) => { tracing::debug!( method, - endpoint = %self.config.rpc_url, + endpoint = %self.rpc_url, attempt, - elapsed_ms = started_at.elapsed().as_millis(), + elapsed_ms = started.elapsed().as_millis(), error = %e, "RPC request failed" ); - last_error = Some(PrismError::RpcError(format!("Request failed: {e}"))); + last_error = Some(PrismError::RpcError(format!("HTTP request failed: {e}"))); } } } - Err(last_error.unwrap_or_else(|| PrismError::RpcError("Unknown error".to_string()))) + Err(last_error.unwrap_or_else(|| PrismError::RpcError("Unknown RPC error".into()))) } } @@ -425,4 +404,52 @@ mod tests { let status: TransactionStatus = serde_json::from_str("\"FAILED\"").unwrap(); assert_eq!(status, TransactionStatus::Failed); } + + #[test] + fn test_simulate_response_is_success() { + let ok = SimulateTransactionResponse { + latest_ledger: 100, + soroban_data: Some("AAAA".to_string()), + min_resource_fee: Some("1000".to_string()), + auth: vec![], + results: vec![], + error: None, + events: vec![], + cost: None, + }; + assert!(ok.is_success()); + + let err = SimulateTransactionResponse { + error: Some("contract trap".to_string()), + ..ok + }; + assert!(!err.is_success()); + } + + #[test] + fn test_simulate_response_deserialization() { + let json = r#"{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "latestLedger": 200, + "transactionData": "AAAAXDR=", + "minResourceFee": "5000", + "auth": ["AUTHXDR="], + "results": [{"xdr": "RETVAL=", "auth": []}], + "events": [] + } + }"#; + + let resp: JsonRpcResponse = + serde_json::from_str(json).unwrap(); + let result = resp.result.unwrap(); + + assert_eq!(result.latest_ledger, 200); + assert_eq!(result.soroban_data.as_deref(), Some("AAAAXDR=")); + assert_eq!(result.min_resource_fee.as_deref(), Some("5000")); + assert_eq!(result.auth, vec!["AUTHXDR="]); + assert_eq!(result.return_value_xdr(), Some("RETVAL=")); + assert!(result.is_success()); + } } diff --git a/crates/core/src/replay/state.rs b/crates/core/src/replay/state.rs index 147ad231..8cf170d9 100644 --- a/crates/core/src/replay/state.rs +++ b/crates/core/src/replay/state.rs @@ -33,15 +33,13 @@ const HOT_PATH_THRESHOLD: u32 = 50_000; /// Reconstruct ledger state at the time of a transaction. pub async fn reconstruct_state(tx_hash: &str, network: &NetworkConfig) -> PrismResult { - let rpc = crate::network::rpc::RpcClient::new(network.clone()); + let rpc = crate::network::rpc::SorobanRpcClient::new(network); // 1. Fetch the transaction to determine its ledger sequence let tx_data = rpc.get_transaction(tx_hash).await?; let tx_ledger = tx_data - .get("ledger") - .and_then(|l| l.as_u64()) - .ok_or_else(|| PrismError::ReplayError("Cannot determine transaction ledger".to_string()))? - as u32; + .ledger + .ok_or_else(|| PrismError::ReplayError("Cannot determine transaction ledger".to_string()))?; // 2. Get the current latest ledger let latest = rpc.get_latest_ledger().await?; @@ -62,7 +60,7 @@ pub async fn reconstruct_state(tx_hash: &str, network: &NetworkConfig) -> PrismR /// Hot path: reconstruct state from Soroban RPC. async fn reconstruct_hot_path( ledger_sequence: u32, - _rpc: &crate::network::rpc::RpcClient, + _rpc: &crate::network::rpc::SorobanRpcClient, ) -> PrismResult { // TODO: Use getLedgerEntries to fetch all entries in the transaction's footprint Ok(LedgerState {