diff --git a/crates/core/src/network/mod.rs b/crates/core/src/network/mod.rs index 2f5e2a42..1d9a1cd2 100644 --- a/crates/core/src/network/mod.rs +++ b/crates/core/src/network/mod.rs @@ -3,3 +3,8 @@ pub mod archive; pub mod config; 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 f3518f40..f027eee5 100644 --- a/crates/core/src/network/rpc.rs +++ b/crates/core/src/network/rpc.rs @@ -10,6 +10,109 @@ use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; use serde::{Deserialize, Serialize}; use std::time::{Duration, 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()) + } +} + /// Primary entry point for Soroban network communication. #[derive(Debug, Clone)] pub struct SorobanRpcClient { @@ -42,33 +145,6 @@ struct JsonRpcError { message: String, } -/// Transaction status in Soroban. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum TransactionStatus { - Success, - NotFound, - Failed, -} - -/// Response for the `getTransaction` RPC method. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GetTransactionResponse { - pub status: TransactionStatus, - pub latest_ledger: u32, - pub latest_ledger_close_time: Option, - pub oldest_ledger: Option, - pub oldest_ledger_close_time: Option, - pub ledger: Option, - pub created_at: Option, - pub application_order: Option, - pub fee_bump: Option, - pub envelope_xdr: Option, - pub result_xdr: Option, - pub result_meta_xdr: Option, -} - impl SorobanRpcClient { /// Create a new `SorobanRpcClient` from a [`NetworkConfig`]. pub fn new(config: &NetworkConfig) -> Self { @@ -93,12 +169,40 @@ impl SorobanRpcClient { self.call("getTransaction", params).await } - /// Simulate a transaction given its XDR envelope. - 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 response: SimulateTransactionResponse = + self.call("simulateTransaction", params).await?; + + // 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) } /// Fetch ledger entries by their XDR keys. @@ -127,6 +231,7 @@ impl SorobanRpcClient { self.call("getLatestLedger", serde_json::json!({})).await } + /// Internal JSON-RPC call with retry and rate-limit backoff. async fn call Deserialize<'de>>( &self, method: &str, @@ -262,4 +367,51 @@ mod tests { assert_eq!(got, expected); } } + #[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()); + } }