base-simulacrum 0.1.0

A headless CLI tool for locally testing EIP-5792 batch transactions against a simulated Base environment
Documentation
//! Local gas sponsorship simulator.
//!
//! This module provides mock paymaster functionality for testing gas-sponsored
//! transactions without requiring a real paymaster service.

use alloy::primitives::U256;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::types::Call;

#[derive(Error, Debug)]
pub enum PaymasterError {
    #[error("Gas estimation failed: {0}")]
    EstimationFailed(String),
    #[error("Sponsorship not available for this transaction")]
    SponsorshipUnavailable,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GasEstimate {
    pub total_gas: U256,
    pub per_call_gas: Vec<U256>,
    pub sponsored: bool,
    pub sponsor_signature: Option<String>,
}

pub struct LocalPaymaster {
    base_gas_per_call: u64,
    sponsorship_enabled: bool,
}

impl LocalPaymaster {
    pub fn new(sponsorship_enabled: bool) -> Self {
        Self {
            base_gas_per_call: 21000,
            sponsorship_enabled,
        }
    }

    pub async fn estimate_batch_gas(&self, calls: &[Call]) -> Result<GasEstimate, PaymasterError> {
        let mut per_call_gas = Vec::new();
        let mut total_gas = U256::ZERO;

        for call in calls {
            let estimated = self.estimate_single_call(call).await?;
            per_call_gas.push(estimated);
            total_gas += estimated;
        }

        let batch_overhead = U256::from(5000 * calls.len() as u64);
        total_gas += batch_overhead;

        let sponsor_signature = if self.sponsorship_enabled {
            Some(self.generate_mock_sponsor_signature(&total_gas))
        } else {
            None
        };

        Ok(GasEstimate {
            total_gas,
            per_call_gas,
            sponsored: self.sponsorship_enabled,
            sponsor_signature,
        })
    }

    async fn estimate_single_call(&self, call: &Call) -> Result<U256, PaymasterError> {
        if let Some(gas) = call.gas {
            return Ok(gas);
        }

        let base = U256::from(self.base_gas_per_call);
        let data_cost = U256::from(call.data.len() as u64 * 16);
        let value_cost = if call.value.is_some() {
            U256::from(9000)
        } else {
            U256::ZERO
        };

        Ok(base + data_cost + value_cost)
    }

    fn generate_mock_sponsor_signature(&self, total_gas: &U256) -> String {
        let mock_sig = format!(
            "0xsponsor_{:x}_mock_signature_v1",
            total_gas.to_string().len()
        );
        mock_sig
    }

    pub fn apply_sponsorship(&self, estimate: &mut GasEstimate) {
        if self.sponsorship_enabled {
            estimate.sponsored = true;
            estimate.sponsor_signature = Some(self.generate_mock_sponsor_signature(&estimate.total_gas));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloy::primitives::{address, Bytes};

    #[tokio::test]
    async fn test_gas_estimation() {
        let paymaster = LocalPaymaster::new(true);
        let call = Call {
            to: address!("0000000000000000000000000000000000000001"),
            data: Bytes::from(vec![0u8; 100]),
            value: None,
            gas: None,
        };

        let estimate = paymaster.estimate_batch_gas(&[call]).await.unwrap();
        assert!(estimate.total_gas > U256::ZERO);
        assert!(estimate.sponsored);
        assert!(estimate.sponsor_signature.is_some());
    }
}