base-simulacrum 0.1.0

A headless CLI tool for locally testing EIP-5792 batch transactions against a simulated Base environment
Documentation
//! EIP-5792 implementation.
//!
//! This module implements the EIP-5792 Wallet Call API, providing `wallet_sendCalls`
//! and `wallet_getCallsStatus` functionality for batch transaction execution.

use alloy::primitives::U256;
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::TransactionRequest;
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;
use uuid::Uuid;

use crate::paymaster::{LocalPaymaster, PaymasterError};
use crate::types::{BatchExecution, CallStatus, CallStatusResponse, Receipt, SendCallsParams};

#[derive(Error, Debug)]
pub enum EngineError {
    #[error("Batch execution not found: {0}")]
    BatchNotFound(String),
    #[error("RPC error: {0}")]
    RpcError(String),
    #[error("Transaction failed: {0}")]
    TransactionFailed(String),
    #[error("Paymaster error: {0}")]
    PaymasterError(#[from] PaymasterError),
    #[error("Invalid parameters: {0}")]
    InvalidParams(String),
}

pub struct Eip5792Engine {
    rpc_url: String,
    paymaster: LocalPaymaster,
    executions: Arc<RwLock<HashMap<Uuid, BatchExecution>>>,
}

impl Eip5792Engine {
    pub fn new(rpc_url: String, gas_sponsorship: bool) -> Self {
        Self {
            rpc_url,
            paymaster: LocalPaymaster::new(gas_sponsorship),
            executions: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    pub async fn wallet_send_calls(&self, params: SendCallsParams) -> Result<String, EngineError> {
        println!(" Processing batch with {} calls", params.calls.len());

        let sponsored = params
            .capabilities
            .as_ref()
            .and_then(|c| c.get("paymasterService"))
            .is_some();

        let gas_estimate = self.paymaster.estimate_batch_gas(&params.calls).await?;
        
        if sponsored {
            println!(
                " Gas sponsorship enabled (estimated: {} gas)",
                gas_estimate.total_gas
            );
        }

        let execution = BatchExecution::new(params.clone(), sponsored);
        let batch_id = execution.id;

        {
            let mut executions = self.executions.write().await;
            executions.insert(batch_id, execution);
        }

        let receipts = self.execute_calls(&params, &gas_estimate.per_call_gas).await?;

        {
            let mut executions = self.executions.write().await;
            if let Some(exec) = executions.get_mut(&batch_id) {
                exec.status = CallStatus::Confirmed;
                exec.receipts = receipts;
            }
        }

        Ok(format!("0x{}", batch_id.simple()))
    }

    async fn execute_calls(
        &self,
        params: &SendCallsParams,
        gas_estimates: &[U256],
    ) -> Result<Vec<Receipt>, EngineError> {
        let provider = ProviderBuilder::new()
            .on_http(self.rpc_url.parse().map_err(|e| {
                EngineError::RpcError(format!("Invalid RPC URL: {}", e))
            })?);

        let mut receipts = Vec::new();

        for (idx, call) in params.calls.iter().enumerate() {
            println!("  → Executing call {}/{} to {}", idx + 1, params.calls.len(), call.to);

            let gas_limit = gas_estimates
                .get(idx)
                .copied()
                .unwrap_or(U256::from(100000))
                .to::<u64>();

            let tx = TransactionRequest::default()
                .from(params.from)
                .to(call.to)
                .input(call.data.clone().into())
                .value(call.value.unwrap_or(U256::ZERO))
                .gas_limit(gas_limit);

            let pending = provider
                .send_transaction(tx)
                .await
                .map_err(|e| EngineError::RpcError(format!("Failed to send transaction: {}", e)))?;

            let tx_hash = *pending.tx_hash();

            let receipt = pending
                .get_receipt()
                .await
                .map_err(|e| EngineError::RpcError(format!("Failed to get receipt: {}", e)))?;

            let status = if receipt.status() { "0x1" } else { "0x0" };

            receipts.push(Receipt {
                logs: Vec::new(),
                status: status.to_string(),
                block_hash: format!("{:?}", receipt.block_hash.unwrap_or_default()),
                block_number: format!("0x{:x}", receipt.block_number.unwrap_or_default()),
                gas_used: format!("0x{:x}", receipt.gas_used),
                transaction_hash: format!("{:?}", tx_hash),
            });

            if !receipt.status() {
                return Err(EngineError::TransactionFailed(format!(
                    "Call {} failed",
                    idx + 1
                )));
            }

            println!("    ✓ Call {} confirmed (gas: {})", idx + 1, receipt.gas_used);
        }

        Ok(receipts)
    }

    pub async fn wallet_get_calls_status(&self, batch_id: &str) -> Result<CallStatusResponse, EngineError> {
        let uuid = self.parse_batch_id(batch_id)?;

        let executions = self.executions.read().await;
        let execution = executions
            .get(&uuid)
            .ok_or_else(|| EngineError::BatchNotFound(batch_id.to_string()))?;

        Ok(CallStatusResponse {
            status: execution.status.clone(),
            receipts: execution.receipts.clone(),
        })
    }

    fn parse_batch_id(&self, batch_id: &str) -> Result<Uuid, EngineError> {
        let id_str = batch_id.strip_prefix("0x").unwrap_or(batch_id);
        Uuid::parse_str(id_str)
            .map_err(|_| EngineError::InvalidParams(format!("Invalid batch ID: {}", batch_id)))
    }

    pub async fn list_executions(&self) -> Vec<(Uuid, CallStatus)> {
        let executions = self.executions.read().await;
        executions
            .iter()
            .map(|(id, exec)| (*id, exec.status.clone()))
            .collect()
    }
}