Skip to main content

cow_settlement/
simulator.rs

1//! Trade simulation for estimating gas costs and detecting reverts.
2//!
3//! Provides [`TradeSimulator`] for simulating settlement execution against
4//! an Ethereum node via JSON-RPC, and [`SimulationResult`] for inspecting
5//! the outcome.
6
7use std::fmt;
8
9use alloy_primitives::Address;
10use cow_chains::{chain::SupportedChainId, contracts::settlement_contract};
11use cow_errors::CowError;
12
13use super::encoder::SettlementEncoder;
14
15/// Result of simulating a settlement transaction via `eth_call`.
16///
17/// Contains the success status, estimated gas usage, and raw return data
18/// from the simulated call.
19///
20/// # Example
21///
22/// ```
23/// use cow_settlement::simulator::SimulationResult;
24///
25/// let result = SimulationResult::new(true, 150_000, vec![]);
26/// assert!(result.is_success());
27/// assert!(!result.is_revert());
28/// assert_eq!(result.gas_used, 150_000);
29/// ```
30#[derive(Debug, Clone)]
31pub struct SimulationResult {
32    /// Whether the simulation completed without reverting.
33    pub success: bool,
34    /// Estimated gas consumed by the simulated transaction.
35    pub gas_used: u64,
36    /// Raw bytes returned by the simulated call.
37    pub return_data: Vec<u8>,
38}
39
40impl SimulationResult {
41    /// Create a new simulation result.
42    ///
43    /// # Arguments
44    ///
45    /// * `success` - Whether the simulation succeeded.
46    /// * `gas_used` - Estimated gas consumed.
47    /// * `return_data` - Raw return bytes from the call.
48    ///
49    /// # Returns
50    ///
51    /// A new [`SimulationResult`].
52    #[must_use]
53    pub const fn new(success: bool, gas_used: u64, return_data: Vec<u8>) -> Self {
54        Self { success, gas_used, return_data }
55    }
56
57    /// Check whether the simulation succeeded (did not revert).
58    ///
59    /// # Returns
60    ///
61    /// `true` if the simulated transaction completed without reverting.
62    #[must_use]
63    pub const fn is_success(&self) -> bool {
64        self.success
65    }
66
67    /// Check whether the simulation reverted.
68    ///
69    /// # Returns
70    ///
71    /// `true` if the simulated transaction reverted.
72    #[must_use]
73    pub const fn is_revert(&self) -> bool {
74        !self.success
75    }
76}
77
78impl fmt::Display for SimulationResult {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        if self.success {
81            write!(f, "Success (gas: {})", self.gas_used)
82        } else {
83            write!(f, "Revert (gas: {}, data: {} bytes)", self.gas_used, self.return_data.len())
84        }
85    }
86}
87
88/// Simulates settlement execution to estimate gas costs and detect reverts.
89///
90/// Wraps a `reqwest::Client` targeting a JSON-RPC endpoint and the canonical
91/// `GPv2Settlement` contract on a specific chain.
92///
93/// # Example
94///
95/// ```rust
96/// use cow_chains::SupportedChainId;
97/// use cow_settlement::simulator::TradeSimulator;
98///
99/// let sim = TradeSimulator::new("https://rpc.sepolia.org", SupportedChainId::Sepolia);
100/// ```
101#[derive(Debug, Clone)]
102pub struct TradeSimulator {
103    /// The JSON-RPC endpoint URL.
104    rpc_url: String,
105    /// HTTP client for making RPC requests.
106    client: reqwest::Client,
107    /// The settlement contract address on the target chain.
108    settlement: Address,
109}
110
111impl TradeSimulator {
112    /// Build a `reqwest::Client` with platform-appropriate settings.
113    ///
114    /// # Returns
115    ///
116    /// A configured [`reqwest::Client`] with a 30-second timeout on native targets,
117    /// or a default client on WASM targets.
118    #[allow(clippy::shadow_reuse, reason = "builder pattern chains naturally shadow")]
119    fn build_client() -> reqwest::Client {
120        let builder = reqwest::Client::builder();
121        #[cfg(not(target_arch = "wasm32"))]
122        let builder = builder.timeout(std::time::Duration::from_secs(30));
123        builder.build().unwrap_or_default()
124    }
125
126    /// Create a new trade simulator for the given chain.
127    ///
128    /// Uses the canonical `GPv2Settlement` contract address for `chain`.
129    ///
130    /// # Arguments
131    ///
132    /// * `rpc_url` - The JSON-RPC endpoint URL.
133    /// * `chain` - The target [`SupportedChainId`].
134    ///
135    /// # Returns
136    ///
137    /// A new [`TradeSimulator`] configured for the specified chain.
138    #[must_use]
139    pub fn new(rpc_url: impl Into<String>, chain: SupportedChainId) -> Self {
140        Self {
141            rpc_url: rpc_url.into(),
142            client: Self::build_client(),
143            settlement: settlement_contract(chain),
144        }
145    }
146
147    /// Return the settlement contract address this simulator targets.
148    ///
149    /// # Returns
150    ///
151    /// The settlement contract [`Address`].
152    #[must_use]
153    pub const fn settlement_address(&self) -> Address {
154        self.settlement
155    }
156
157    /// Return the RPC URL this simulator is configured to use.
158    ///
159    /// # Returns
160    ///
161    /// A reference to the RPC URL string.
162    #[must_use]
163    pub fn rpc_url(&self) -> &str {
164        &self.rpc_url
165    }
166
167    /// Estimate the gas cost of executing calldata against the settlement contract.
168    ///
169    /// Sends an `eth_estimateGas` JSON-RPC request with the provided calldata
170    /// targeting the settlement contract.
171    ///
172    /// # Arguments
173    ///
174    /// * `calldata` - The ABI-encoded calldata to estimate gas for.
175    ///
176    /// # Returns
177    ///
178    /// The estimated gas as `u64`.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`CowError::Rpc`] if the RPC request fails or the node returns
183    /// an error (e.g., the transaction would revert).
184    pub async fn estimate_gas(&self, calldata: &[u8]) -> Result<u64, CowError> {
185        let to_hex = format!("{:#x}", self.settlement);
186        let data_hex = format!("0x{}", alloy_primitives::hex::encode(calldata));
187
188        let body = serde_json::json!({
189            "jsonrpc": "2.0",
190            "method":  "eth_estimateGas",
191            "params":  [{"to": to_hex, "data": data_hex}],
192            "id":      1u32
193        });
194
195        let resp = self.client.post(&self.rpc_url).json(&body).send().await?;
196
197        if !resp.status().is_success() {
198            let code = i64::from(resp.status().as_u16());
199            let msg = resp.text().await.unwrap_or_else(|_e| String::new());
200            return Err(CowError::Rpc { code, message: msg });
201        }
202
203        let rpc: RpcResponse = resp.json().await?;
204
205        if let Some(err) = rpc.error {
206            return Err(CowError::Rpc { code: err.code, message: err.message });
207        }
208
209        let hex_str = rpc
210            .result
211            .ok_or_else(|| CowError::Rpc { code: -1, message: "missing result field".into() })?;
212
213        parse_hex_u64(&hex_str)
214    }
215
216    /// Simulate executing calldata against the settlement contract via `eth_call`.
217    ///
218    /// Unlike [`estimate_gas`](Self::estimate_gas), this method does not fail on
219    /// reverts — instead it returns a [`SimulationResult`] indicating whether the
220    /// call succeeded or reverted.
221    ///
222    /// # Arguments
223    ///
224    /// * `calldata` - The ABI-encoded calldata to simulate.
225    ///
226    /// # Returns
227    ///
228    /// A [`SimulationResult`] with success status, gas estimate, and return data.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`CowError::Rpc`] only on transport-level failures (HTTP errors).
233    /// Execution reverts are captured in the returned [`SimulationResult`].
234    pub async fn simulate(&self, calldata: &[u8]) -> Result<SimulationResult, CowError> {
235        let to_hex = format!("{:#x}", self.settlement);
236        let data_hex = format!("0x{}", alloy_primitives::hex::encode(calldata));
237
238        let body = serde_json::json!({
239            "jsonrpc": "2.0",
240            "method":  "eth_call",
241            "params":  [{"to": to_hex, "data": data_hex}, "latest"],
242            "id":      1u32
243        });
244
245        let resp = self.client.post(&self.rpc_url).json(&body).send().await?;
246
247        if !resp.status().is_success() {
248            let code = i64::from(resp.status().as_u16());
249            let msg = resp.text().await.unwrap_or_else(|_e| String::new());
250            return Err(CowError::Rpc { code, message: msg });
251        }
252
253        let rpc: RpcResponse = resp.json().await?;
254
255        if let Some(err) = rpc.error {
256            // Execution revert — capture as a failed simulation rather than
257            // propagating as an error.
258            return Ok(SimulationResult::new(false, 0, err.message.into_bytes()));
259        }
260
261        let hex_str = rpc
262            .result
263            .ok_or_else(|| CowError::Rpc { code: -1, message: "missing result field".into() })?;
264
265        let return_data = decode_hex_result(&hex_str)?;
266
267        // Attempt a gas estimate for successful simulations.
268        let gas_used = self.estimate_gas(calldata).await.unwrap_or_default();
269
270        Ok(SimulationResult::new(true, gas_used, return_data))
271    }
272
273    /// Convenience method: encode a settlement and estimate its gas cost.
274    ///
275    /// Combines [`SettlementEncoder::encode_settlement`] with
276    /// [`estimate_gas`](Self::estimate_gas).
277    ///
278    /// # Arguments
279    ///
280    /// * `encoder` - The [`SettlementEncoder`] containing the settlement to estimate.
281    ///
282    /// # Returns
283    ///
284    /// The estimated gas as `u64`.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`CowError::Rpc`] if the RPC request fails or the settlement
289    /// would revert.
290    pub async fn estimate_settlement(&self, encoder: &SettlementEncoder) -> Result<u64, CowError> {
291        let calldata = encoder.encode_settlement();
292        self.estimate_gas(&calldata).await
293    }
294}
295
296// ── JSON-RPC response types (private) ────────────────────────────────────────
297
298#[derive(serde::Deserialize)]
299struct RpcResponse {
300    result: Option<String>,
301    error: Option<RpcError>,
302}
303
304#[derive(serde::Deserialize)]
305struct RpcError {
306    code: i64,
307    message: String,
308}
309
310// ── Private helpers ──────────────────────────────────────────────────────────
311
312/// Parse a `0x`-prefixed hex string as a `u64`.
313fn parse_hex_u64(hex_str: &str) -> Result<u64, CowError> {
314    let clean = hex_str.trim_start_matches("0x");
315    u64::from_str_radix(clean, 16)
316        .map_err(|e| CowError::Parse { field: "gas_estimate", reason: format!("invalid hex: {e}") })
317}
318
319/// Decode a `0x`-prefixed hex result string into bytes.
320fn decode_hex_result(hex_str: &str) -> Result<Vec<u8>, CowError> {
321    let clean = hex_str.trim_start_matches("0x");
322    alloy_primitives::hex::decode(clean)
323        .map_err(|e| CowError::Rpc { code: -1, message: format!("hex decode: {e}") })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use cow_chains::contracts::SETTLEMENT_CONTRACT;
330
331    // ── SimulationResult tests ───────────────────────────────────────────
332
333    #[test]
334    fn simulation_result_new() {
335        let result = SimulationResult::new(true, 100_000, vec![0xab, 0xcd]);
336        assert!(result.success);
337        assert_eq!(result.gas_used, 100_000);
338        assert_eq!(result.return_data, vec![0xab, 0xcd]);
339    }
340
341    #[test]
342    fn simulation_result_is_success() {
343        let success = SimulationResult::new(true, 50_000, vec![]);
344        assert!(success.is_success());
345        assert!(!success.is_revert());
346    }
347
348    #[test]
349    fn simulation_result_is_revert() {
350        let revert = SimulationResult::new(false, 0, vec![0xff]);
351        assert!(!revert.is_success());
352        assert!(revert.is_revert());
353    }
354
355    #[test]
356    fn simulation_result_display_success() {
357        let result = SimulationResult::new(true, 150_000, vec![]);
358        assert_eq!(format!("{result}"), "Success (gas: 150000)");
359    }
360
361    #[test]
362    fn simulation_result_display_revert() {
363        let result = SimulationResult::new(false, 21_000, vec![0xde, 0xad]);
364        assert_eq!(format!("{result}"), "Revert (gas: 21000, data: 2 bytes)");
365    }
366
367    #[test]
368    fn simulation_result_clone() {
369        let result = SimulationResult::new(true, 42, vec![1, 2, 3]);
370        let cloned = result.clone();
371        assert_eq!(cloned.success, result.success);
372        assert_eq!(cloned.gas_used, result.gas_used);
373        assert_eq!(cloned.return_data, result.return_data);
374    }
375
376    // ── TradeSimulator construction tests ────────────────────────────────
377
378    #[test]
379    fn trade_simulator_new_mainnet() {
380        let sim = TradeSimulator::new("https://eth.example.com", SupportedChainId::Mainnet);
381        assert_eq!(sim.settlement_address(), SETTLEMENT_CONTRACT);
382        assert_eq!(sim.rpc_url(), "https://eth.example.com");
383    }
384
385    #[test]
386    fn trade_simulator_new_sepolia() {
387        let sim = TradeSimulator::new("https://sepolia.example.com", SupportedChainId::Sepolia);
388        assert_eq!(sim.settlement_address(), settlement_contract(SupportedChainId::Sepolia));
389        assert_eq!(sim.rpc_url(), "https://sepolia.example.com");
390    }
391
392    #[test]
393    fn trade_simulator_new_gnosis() {
394        let sim = TradeSimulator::new("https://gnosis.example.com", SupportedChainId::GnosisChain);
395        assert_eq!(sim.settlement_address(), settlement_contract(SupportedChainId::GnosisChain));
396    }
397
398    #[test]
399    fn trade_simulator_new_arbitrum() {
400        let sim = TradeSimulator::new("https://arb.example.com", SupportedChainId::ArbitrumOne);
401        assert_eq!(sim.settlement_address(), settlement_contract(SupportedChainId::ArbitrumOne));
402    }
403
404    #[test]
405    fn trade_simulator_clone() {
406        let sim = TradeSimulator::new("https://example.com", SupportedChainId::Mainnet);
407        let cloned = sim.clone();
408        assert_eq!(cloned.settlement_address(), sim.settlement_address());
409        assert_eq!(cloned.rpc_url(), sim.rpc_url());
410    }
411
412    // ── Helper tests ────────────────────────────────────────────────────
413
414    #[test]
415    fn parse_hex_u64_valid() {
416        assert_eq!(parse_hex_u64("0x5208").unwrap(), 21_000);
417    }
418
419    #[test]
420    fn parse_hex_u64_no_prefix() {
421        assert_eq!(parse_hex_u64("ff").unwrap(), 255);
422    }
423
424    #[test]
425    fn parse_hex_u64_invalid() {
426        assert!(parse_hex_u64("0xZZZZ").is_err());
427    }
428
429    #[test]
430    fn decode_hex_result_valid() {
431        let bytes = decode_hex_result("0xdeadbeef").unwrap();
432        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
433    }
434
435    #[test]
436    fn decode_hex_result_empty() {
437        let bytes = decode_hex_result("0x").unwrap();
438        assert!(bytes.is_empty());
439    }
440}