Skip to main content

sdkt_rpc/
simulate.rs

1//! Soroban transaction simulation.
2//!
3//! Provides `simulate_transaction` to dry-run a transaction envelope against
4//! the network without submitting it, returning resource usage and auth entries.
5
6use crate::{RpcError, SorobanRpcClient};
7use serde::{Deserialize, Deserializer, Serialize};
8
9/// Helper to deserialize either a string or an integer into an Option<String>.
10fn deserialize_optional_string_or_int<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
11where
12    D: Deserializer<'de>,
13{
14    use serde::de::Error;
15    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
16    Ok(match value {
17        Some(serde_json::Value::String(s)) => Some(s),
18        Some(serde_json::Value::Number(n)) => Some(n.to_string()),
19        Some(_) => return Err(Error::custom("expected string or number")),
20        None => None,
21    })
22}
23
24/// Wrapper for the `simulateTransaction` RPC call.
25#[derive(Debug, Serialize)]
26pub struct SimulateTransactionRequest {
27    pub transaction: String,
28}
29
30/// Result of simulating a single operation within the transaction.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[serde(rename_all = "camelCase")]
33pub struct SimulateOperationResult {
34    /// List of base64 `SorobanAuthorizationEntry` XDR blobs (may be empty).
35    #[serde(default)]
36    pub auth: Vec<String>,
37    /// Base64 XDR of the `SorobanTransactionMeta` entry for the operation.
38    #[serde(default)]
39    pub xdr: String,
40}
41
42/// Resource consumption report from a simulation.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44#[serde(rename_all = "camelCase")]
45pub struct SimulateCost {
46    pub cpu_insns: String,
47    pub mem_bytes: String,
48}
49
50/// Additional metadata provided by some RPC nodes for transactions.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(rename_all = "camelCase")]
53pub struct SimulateRestorePreamble {
54    pub min_resource_fee: String,
55    pub transaction_data: String,
56}
57
58/// Full response from the `simulateTransaction` endpoint.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60#[serde(rename_all = "camelCase")]
61pub struct SimulateResponse {
62    /// Base64 XDR `SorobanTransactionData` returned for building the real transaction.
63    #[serde(default)]
64    pub transaction_data: String,
65    /// Minimum resource fee in stroops.
66    #[serde(default)]
67    pub min_resource_fee: String,
68    /// State restoration preamble data if the transaction requires expired state restoration.
69    #[serde(default)]
70    pub restore_preamble: Option<SimulateRestorePreamble>,
71    /// Per-operation results (present for host-function invocation transactions).
72    #[serde(default)]
73    pub results: Vec<SimulateOperationResult>,
74    /// CPU and memory resource cost.
75    #[serde(default)]
76    pub cost: Option<SimulateCost>,
77    /// Ledger sequence the simulation was run against.
78    /// Accepts both string and integer values from Testnet.
79    #[serde(default, deserialize_with = "deserialize_optional_string_or_int")]
80    pub latest_ledger: Option<String>,
81    /// Diagnostic events emitted during simulation.
82    #[serde(default)]
83    pub events: Vec<serde_json::Value>,
84    /// State changes emitted during simulation.
85    #[serde(default)]
86    pub state_changes: Vec<serde_json::Value>,
87    /// Optional simulation error (e.g. host function failure). A populated
88    /// `error` field indicates the simulation did not fully succeed.
89    #[serde(default)]
90    pub error: Option<String>,
91}
92
93/// Runs `simulateTransaction` against the network for a base64 envelope XDR.
94///
95/// Follows the existing RPC module pattern: a standalone async function that
96/// reuses `SorobanRpcClient::request` for JSON-RPC transport, timeout and retry.
97pub async fn simulate_transaction(
98    client: &SorobanRpcClient,
99    envelope: &str,
100) -> Result<SimulateResponse, RpcError> {
101    if envelope.trim().is_empty() {
102        return Err(RpcError::Rpc("Transaction envelope is empty".to_string()));
103    }
104
105    let request = SimulateTransactionRequest {
106        transaction: envelope.to_string(),
107    };
108
109    let response: SimulateResponse = client.request("simulateTransaction", request).await?;
110
111    Ok(response)
112}
113
114/// Validates that a transaction envelope is non-empty. Exposed as a pure helper
115/// so the guard can be unit-tested without a live `SorobanRpcClient`.
116pub fn validate_envelope(envelope: &str) -> Result<(), RpcError> {
117    if envelope.trim().is_empty() {
118        return Err(RpcError::Rpc("Transaction envelope is empty".to_string()));
119    }
120    Ok(())
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_request_serialization() {
129        let request = SimulateTransactionRequest {
130            transaction: "AAAAEnvelope===".to_string(),
131        };
132        let json = serde_json::to_value(request).unwrap();
133        assert_eq!(json["transaction"], "AAAAEnvelope===");
134    }
135
136    #[test]
137    fn test_response_deserialize_full() {
138        let raw = r#"{
139            "transactionData": "AAAAAdata",
140            "minResourceFee": "1000",
141            "restorePreamble": {
142                "minResourceFee": "500",
143                "transactionData": "AAAABdata"
144            },
145            "results": [
146                {"auth": ["AAAAauth1"], "xdr": "AAAAxdr1"}
147            ],
148            "cost": {"cpuInsns": "5000", "memBytes": "2048"},
149            "latestLedger": "12345",
150            "events": [{"type": "diagnostic"}],
151            "stateChanges": [{"type": "ledgerEntry"}]
152        }"#;
153        let response: SimulateResponse = serde_json::from_str(raw).unwrap();
154        assert_eq!(response.transaction_data, "AAAAAdata");
155        assert_eq!(response.min_resource_fee, "1000");
156        let restore = response.restore_preamble.unwrap();
157        assert_eq!(restore.min_resource_fee, "500");
158        assert_eq!(restore.transaction_data, "AAAABdata");
159        assert_eq!(response.results.len(), 1);
160        assert_eq!(response.results[0].auth, vec!["AAAAauth1".to_string()]);
161        assert_eq!(response.results[0].xdr, "AAAAxdr1");
162        let cost = response.cost.unwrap();
163        assert_eq!(cost.cpu_insns, "5000");
164        assert_eq!(cost.mem_bytes, "2048");
165        assert_eq!(response.latest_ledger, Some("12345".to_string()));
166        assert_eq!(response.events.len(), 1);
167        assert_eq!(response.state_changes.len(), 1);
168        assert_eq!(response.error, None);
169    }
170
171    #[test]
172    fn test_response_deserialize_latest_ledger_as_integer() {
173        // Verify the fix: Testnet returns integer, not string.
174        let raw = r#"{
175            "transactionData": "AAAAAdata",
176            "latestLedger": 4718305
177        }"#;
178        let response: SimulateResponse = serde_json::from_str(raw).unwrap();
179        assert_eq!(response.latest_ledger, Some("4718305".to_string()));
180    }
181
182    #[test]
183    fn test_response_deserialize_minimal() {
184        // Minimal valid response with only transactionData present.
185        let raw = r#"{"transactionData": "AAAAAdata"}"#;
186        let response: SimulateResponse = serde_json::from_str(raw).unwrap();
187        assert_eq!(response.min_resource_fee, "");
188        assert!(response.restore_preamble.is_none());
189        assert!(response.results.is_empty());
190        assert!(response.cost.is_none());
191        assert!(response.events.is_empty());
192        assert!(response.state_changes.is_empty());
193        assert_eq!(response.error, None);
194    }
195
196    #[test]
197    fn test_response_deserialize_error_field() {
198        let raw = r#"{
199            "transactionData": "",
200            "error": "host function failed"
201        }"#;
202        let response: SimulateResponse = serde_json::from_str(raw).unwrap();
203        assert_eq!(response.error, Some("host function failed".to_string()));
204    }
205
206    #[test]
207    fn test_response_deserialize_malformed() {
208        let raw = r#"{"transactionData": }"#;
209        assert!(serde_json::from_str::<SimulateResponse>(raw).is_err());
210    }
211
212    #[test]
213    fn test_empty_envelope_rejected() {
214        assert!(validate_envelope("   ").is_err());
215        assert!(validate_envelope("").is_err());
216        assert!(validate_envelope("AAAAEnvelope===").is_ok());
217    }
218}