cipher-gate 0.3.0

Proxy RPC that routes signing requests to a browser wallet UI
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::rpc::{JsonRpcError, JsonRpcRequest, JsonRpcResponse};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gas_estimate: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub revert_reason: Option<String>,
}

pub async fn forward_to_upstream(
    client: &Client,
    rpc_url: &str,
    request: &JsonRpcRequest,
) -> JsonRpcResponse {
    let resp = match client.post(rpc_url).json(request).send().await {
        Ok(resp) => resp,
        Err(e) => {
            return JsonRpcResponse::error(
                request.id.clone(),
                -32603,
                format!("Failed to reach upstream RPC: {e}"),
            );
        }
    };

    let body = match resp.text().await {
        Ok(body) => body,
        Err(e) => {
            return JsonRpcResponse::error(
                request.id.clone(),
                -32603,
                format!("Failed to read upstream response body: {e}"),
            );
        }
    };

    // Try parsing as a typed JsonRpcResponse first, fall back to raw Value passthrough
    match serde_json::from_str::<JsonRpcResponse>(&body) {
        Ok(mut rpc_resp) => {
            // Ensure valid JSON-RPC: if neither result nor error is present,
            // set result to null. Some RPCs omit `result` for pending receipts.
            if rpc_resp.result.is_none() && rpc_resp.error.is_none() {
                rpc_resp.result = Some(Value::Null);
            }
            rpc_resp
        }
        Err(_) => {
            // If it's valid JSON at all, wrap it as a raw result so the caller gets something useful
            match serde_json::from_str::<Value>(&body) {
                Ok(raw) => JsonRpcResponse {
                    jsonrpc: "2.0".into(),
                    result: Some(raw),
                    error: None,
                    id: request.id.clone(),
                },
                Err(e) => {
                    eprintln!(
                        "  warn: upstream returned non-JSON for {}: {}",
                        request.method,
                        &body[..body.len().min(200)]
                    );
                    JsonRpcResponse::error(
                        request.id.clone(),
                        -32603,
                        format!("Upstream returned invalid JSON: {e}"),
                    )
                }
            }
        }
    }
}

/// Simulate a transaction via eth_call + eth_estimateGas before routing to wallet.
pub async fn simulate_transaction(
    client: &Client,
    rpc_url: &str,
    params: &Value,
) -> SimulationResult {
    // Extract tx object (first element if array)
    let tx_obj = if let Value::Array(arr) = params {
        arr.first().cloned().unwrap_or(Value::Null)
    } else {
        params.clone()
    };

    let call_params = Value::Array(vec![tx_obj.clone(), Value::String("latest".into())]);
    let gas_params = Value::Array(vec![tx_obj]);

    let call_req = JsonRpcRequest {
        jsonrpc: "2.0".into(),
        method: "eth_call".into(),
        params: call_params,
        id: Value::Number(9990.into()),
    };

    let gas_req = JsonRpcRequest {
        jsonrpc: "2.0".into(),
        method: "eth_estimateGas".into(),
        params: gas_params,
        id: Value::Number(9991.into()),
    };

    let (call_resp, gas_resp) = tokio::join!(
        forward_to_upstream(client, rpc_url, &call_req),
        forward_to_upstream(client, rpc_url, &gas_req),
    );

    let mut result = SimulationResult {
        success: true,
        gas_estimate: None,
        revert_reason: None,
    };

    // Check eth_call result
    if let Some(err) = &call_resp.error {
        result.success = false;
        result.revert_reason = Some(decode_revert_reason(err));
    }

    // Check eth_estimateGas result
    if let Some(ref res) = gas_resp.result {
        if let Some(gas) = res.as_str() {
            result.gas_estimate = Some(gas.to_string());
        }
    } else if let Some(err) = &gas_resp.error
        && result.success
    {
        result.success = false;
        result.revert_reason = Some(decode_revert_reason(err));
    }

    result
}

fn decode_revert_reason(err: &JsonRpcError) -> String {
    if let Some(ref data) = err.data {
        // Revert data can be a hex string or nested in an object
        let hex_data = if let Some(s) = data.as_str() {
            Some(s.to_string())
        } else if let Some(obj) = data.as_object() {
            obj.get("data").and_then(|d| d.as_str()).map(String::from)
        } else {
            None
        };

        if let Some(hex) = hex_data
            && let Some(reason) = try_decode_revert_string(&hex)
        {
            return reason;
        }
    }
    err.message.clone()
}

/// Try to ABI-decode an Error(string) revert.
fn try_decode_revert_string(hex_data: &str) -> Option<String> {
    let hex = hex_data.strip_prefix("0x")?;
    // Need at least selector(8) + offset(64) + length(64) = 136 hex chars
    if hex.len() < 136 {
        return None;
    }

    // Error(string) selector = 08c379a0
    if &hex[..8] != "08c379a0" {
        return None;
    }

    // String length at bytes 36..68 (hex 72..136)
    let len_hex = hex[72..136].trim_start_matches('0');
    let len = if len_hex.is_empty() {
        0
    } else {
        usize::from_str_radix(len_hex, 16).ok()?
    };
    if len == 0 {
        return Some(String::new());
    }

    let data_start = 136;
    let data_end = data_start + len * 2;
    if hex.len() < data_end {
        return None;
    }

    let bytes: Vec<u8> = (0..len)
        .filter_map(|i| {
            u8::from_str_radix(&hex[data_start + i * 2..data_start + i * 2 + 2], 16).ok()
        })
        .collect();

    Some(String::from_utf8_lossy(&bytes).to_string())
}

/// Fetch the chain ID from upstream. Returns the hex string (e.g. "0x1").
pub async fn fetch_chain_id(client: &Client, rpc_url: &str) -> Option<Value> {
    let req = JsonRpcRequest {
        jsonrpc: "2.0".into(),
        method: "eth_chainId".into(),
        params: Value::Array(vec![]),
        id: Value::Number(1.into()),
    };
    let resp = forward_to_upstream(client, rpc_url, &req).await;
    resp.result
}