use std::fmt;
use alloy_primitives::Address;
use crate::{
config::{chain::SupportedChainId, contracts::settlement_contract},
error::CowError,
};
use super::encoder::SettlementEncoder;
#[derive(Debug, Clone)]
pub struct SimulationResult {
pub success: bool,
pub gas_used: u64,
pub return_data: Vec<u8>,
}
impl SimulationResult {
#[must_use]
pub const fn new(success: bool, gas_used: u64, return_data: Vec<u8>) -> Self {
Self { success, gas_used, return_data }
}
#[must_use]
pub const fn is_success(&self) -> bool {
self.success
}
#[must_use]
pub const fn is_revert(&self) -> bool {
!self.success
}
}
impl fmt::Display for SimulationResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.success {
write!(f, "Success (gas: {})", self.gas_used)
} else {
write!(f, "Revert (gas: {}, data: {} bytes)", self.gas_used, self.return_data.len())
}
}
}
#[derive(Debug, Clone)]
pub struct TradeSimulator {
rpc_url: String,
client: reqwest::Client,
settlement: Address,
}
impl TradeSimulator {
#[allow(clippy::shadow_reuse, reason = "builder pattern chains naturally shadow")]
fn build_client() -> reqwest::Client {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder.timeout(std::time::Duration::from_secs(30));
builder.build().unwrap_or_default()
}
#[must_use]
pub fn new(rpc_url: impl Into<String>, chain: SupportedChainId) -> Self {
Self {
rpc_url: rpc_url.into(),
client: Self::build_client(),
settlement: settlement_contract(chain),
}
}
#[must_use]
pub const fn settlement_address(&self) -> Address {
self.settlement
}
#[must_use]
pub fn rpc_url(&self) -> &str {
&self.rpc_url
}
pub async fn estimate_gas(&self, calldata: &[u8]) -> Result<u64, CowError> {
let to_hex = format!("{:#x}", self.settlement);
let data_hex = format!("0x{}", alloy_primitives::hex::encode(calldata));
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [{"to": to_hex, "data": data_hex}],
"id": 1u32
});
let resp = self.client.post(&self.rpc_url).json(&body).send().await?;
if !resp.status().is_success() {
let code = i64::from(resp.status().as_u16());
let msg = resp.text().await.unwrap_or_else(|_e| String::new());
return Err(CowError::Rpc { code, message: msg });
}
let rpc: RpcResponse = resp.json().await?;
if let Some(err) = rpc.error {
return Err(CowError::Rpc { code: err.code, message: err.message });
}
let hex_str = rpc
.result
.ok_or_else(|| CowError::Rpc { code: -1, message: "missing result field".into() })?;
parse_hex_u64(&hex_str)
}
pub async fn simulate(&self, calldata: &[u8]) -> Result<SimulationResult, CowError> {
let to_hex = format!("{:#x}", self.settlement);
let data_hex = format!("0x{}", alloy_primitives::hex::encode(calldata));
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{"to": to_hex, "data": data_hex}, "latest"],
"id": 1u32
});
let resp = self.client.post(&self.rpc_url).json(&body).send().await?;
if !resp.status().is_success() {
let code = i64::from(resp.status().as_u16());
let msg = resp.text().await.unwrap_or_else(|_e| String::new());
return Err(CowError::Rpc { code, message: msg });
}
let rpc: RpcResponse = resp.json().await?;
if let Some(err) = rpc.error {
return Ok(SimulationResult::new(false, 0, err.message.into_bytes()));
}
let hex_str = rpc
.result
.ok_or_else(|| CowError::Rpc { code: -1, message: "missing result field".into() })?;
let return_data = decode_hex_result(&hex_str)?;
let gas_used = self.estimate_gas(calldata).await.unwrap_or_default();
Ok(SimulationResult::new(true, gas_used, return_data))
}
pub async fn estimate_settlement(&self, encoder: &SettlementEncoder) -> Result<u64, CowError> {
let calldata = encoder.encode_settlement();
self.estimate_gas(&calldata).await
}
}
#[derive(serde::Deserialize)]
struct RpcResponse {
result: Option<String>,
error: Option<RpcError>,
}
#[derive(serde::Deserialize)]
struct RpcError {
code: i64,
message: String,
}
fn parse_hex_u64(hex_str: &str) -> Result<u64, CowError> {
let clean = hex_str.trim_start_matches("0x");
u64::from_str_radix(clean, 16)
.map_err(|e| CowError::Parse { field: "gas_estimate", reason: format!("invalid hex: {e}") })
}
fn decode_hex_result(hex_str: &str) -> Result<Vec<u8>, CowError> {
let clean = hex_str.trim_start_matches("0x");
alloy_primitives::hex::decode(clean)
.map_err(|e| CowError::Rpc { code: -1, message: format!("hex decode: {e}") })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::contracts::SETTLEMENT_CONTRACT;
#[test]
fn simulation_result_new() {
let result = SimulationResult::new(true, 100_000, vec![0xab, 0xcd]);
assert!(result.success);
assert_eq!(result.gas_used, 100_000);
assert_eq!(result.return_data, vec![0xab, 0xcd]);
}
#[test]
fn simulation_result_is_success() {
let success = SimulationResult::new(true, 50_000, vec![]);
assert!(success.is_success());
assert!(!success.is_revert());
}
#[test]
fn simulation_result_is_revert() {
let revert = SimulationResult::new(false, 0, vec![0xff]);
assert!(!revert.is_success());
assert!(revert.is_revert());
}
#[test]
fn simulation_result_display_success() {
let result = SimulationResult::new(true, 150_000, vec![]);
assert_eq!(format!("{result}"), "Success (gas: 150000)");
}
#[test]
fn simulation_result_display_revert() {
let result = SimulationResult::new(false, 21_000, vec![0xde, 0xad]);
assert_eq!(format!("{result}"), "Revert (gas: 21000, data: 2 bytes)");
}
#[test]
fn simulation_result_clone() {
let result = SimulationResult::new(true, 42, vec![1, 2, 3]);
let cloned = result.clone();
assert_eq!(cloned.success, result.success);
assert_eq!(cloned.gas_used, result.gas_used);
assert_eq!(cloned.return_data, result.return_data);
}
#[test]
fn trade_simulator_new_mainnet() {
let sim = TradeSimulator::new("https://eth.example.com", SupportedChainId::Mainnet);
assert_eq!(sim.settlement_address(), SETTLEMENT_CONTRACT);
assert_eq!(sim.rpc_url(), "https://eth.example.com");
}
#[test]
fn trade_simulator_new_sepolia() {
let sim = TradeSimulator::new("https://sepolia.example.com", SupportedChainId::Sepolia);
assert_eq!(sim.settlement_address(), settlement_contract(SupportedChainId::Sepolia));
assert_eq!(sim.rpc_url(), "https://sepolia.example.com");
}
#[test]
fn trade_simulator_new_gnosis() {
let sim = TradeSimulator::new("https://gnosis.example.com", SupportedChainId::GnosisChain);
assert_eq!(sim.settlement_address(), settlement_contract(SupportedChainId::GnosisChain));
}
#[test]
fn trade_simulator_new_arbitrum() {
let sim = TradeSimulator::new("https://arb.example.com", SupportedChainId::ArbitrumOne);
assert_eq!(sim.settlement_address(), settlement_contract(SupportedChainId::ArbitrumOne));
}
#[test]
fn trade_simulator_clone() {
let sim = TradeSimulator::new("https://example.com", SupportedChainId::Mainnet);
let cloned = sim.clone();
assert_eq!(cloned.settlement_address(), sim.settlement_address());
assert_eq!(cloned.rpc_url(), sim.rpc_url());
}
#[test]
fn parse_hex_u64_valid() {
assert_eq!(parse_hex_u64("0x5208").unwrap(), 21_000);
}
#[test]
fn parse_hex_u64_no_prefix() {
assert_eq!(parse_hex_u64("ff").unwrap(), 255);
}
#[test]
fn parse_hex_u64_invalid() {
assert!(parse_hex_u64("0xZZZZ").is_err());
}
#[test]
fn decode_hex_result_valid() {
let bytes = decode_hex_result("0xdeadbeef").unwrap();
assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn decode_hex_result_empty() {
let bytes = decode_hex_result("0x").unwrap();
assert!(bytes.is_empty());
}
}