trident-evm-aa 0.3.0

Rust glue for the Trident minimal EIP-7702 smart account: deploy, install, and batch transactions.
Documentation
//! Compose multiple sub-calls into a single transaction against a
//! 7702-delegated `TridentAccount` EOA via Multicall3.

use alloy_network::{Network, TransactionBuilder};
use alloy_primitives::{Address, Bytes, U256, address};
use alloy_provider::{PendingTransactionBuilder, Provider, WalletProvider};
use alloy_sol_types::SolCall;

use crate::abi::{Multicall3, TridentAccount};

/// Canonical Multicall3 deployment address — same on every major EVM chain.
const MULTICALL3: Address = address!("cA11bde05977b3631167028862bE2a173976CA11");

/// Errors surfaced by [`MulticallBuilder::send`].
#[derive(Debug, thiserror::Error)]
pub enum MulticallError {
    /// Transport-level RPC failure.
    #[error("rpc: {0}")]
    Rpc(Box<dyn std::error::Error + Send + Sync>),
}

/// Accumulator for a Multicall3 batch to be run via a self-call into a
/// 7702-delegated `TridentAccount` EOA.
#[derive(Default, Clone)]
pub struct MulticallBuilder {
    calls: Vec<Multicall3::Call3Value>,
    multicall3: Address,
}

impl MulticallBuilder {
    /// New builder with `multicall3` set to the canonical Multicall3 address.
    pub fn new() -> Self {
        Self {
            calls: Vec::new(),
            multicall3: MULTICALL3,
        }
    }

    /// Override the Multicall3 address (tests / non-canonical chains).
    pub fn with_multicall3(mut self, addr: Address) -> Self {
        self.multicall3 = addr;
        self
    }

    /// Append a zero-value contract call.
    pub fn call<C: SolCall>(mut self, contract: Address, call: &C) -> Self {
        self.calls.push(Multicall3::Call3Value {
            target: contract,
            allowFailure: false,
            value: U256::ZERO,
            callData: call.abi_encode().into(),
        });
        self
    }

    /// Append a value-bearing contract call.
    pub fn call_with_value<C: SolCall>(mut self, contract: Address, call: &C, value: U256) -> Self {
        self.calls.push(Multicall3::Call3Value {
            target: contract,
            allowFailure: false,
            value,
            callData: call.abi_encode().into(),
        });
        self
    }

    /// Append a pure ETH transfer (empty calldata).
    pub fn send_value(mut self, to: Address, value: U256) -> Self {
        self.calls.push(Multicall3::Call3Value {
            target: to,
            allowFailure: false,
            value,
            callData: Bytes::new(),
        });
        self
    }

    /// Sum of per-call values — this is the outer `msg.value` that will be
    /// sent to satisfy Multicall3's invariant.
    pub fn total_value(&self) -> U256 {
        self.calls.iter().map(|c| c.value).sum()
    }

    /// Inner calldata: ABI-encoded `Multicall3.aggregate3Value(calls)`.
    pub fn aggregate3_calldata(&self) -> Bytes {
        Multicall3::aggregate3ValueCall {
            calls: self.calls.clone(),
        }
        .abi_encode()
        .into()
    }

    /// Build + send the outer self-call. Convenience wrapper around
    /// [`send`](Self::send) that uses the provider's default signer as the
    /// outer-tx signer. That EOA must be 7702-delegated to `TridentAccount`.
    ///
    /// Returns an [`alloy_provider::PendingTransactionBuilder`].
    pub async fn send_with_wallet_provider<P, N>(
        self,
        provider: P,
    ) -> Result<PendingTransactionBuilder<N>, MulticallError>
    where
        P: Provider<N> + WalletProvider<N>,
        N: Network,
    {
        let tx_signer = provider.default_signer_address();
        self.send(provider, tx_signer).await
    }

    /// Build + send the outer self-call.
    ///
    /// `tx_signer` is the EOA that will sign the outer transaction — the
    /// `from` of the outgoing tx, which is also its `to` because this is a
    /// self-call. It must be 7702-delegated to `TridentAccount`, and it
    /// must match the key `provider` is configured to sign with; otherwise
    /// the provider will fail to sign or the chain will reject the tx.
    ///
    /// Returns an [`alloy_provider::PendingTransactionBuilder`].
    pub async fn send<P, N>(
        self,
        provider: P,
        tx_signer: Address,
    ) -> Result<PendingTransactionBuilder<N>, MulticallError>
    where
        P: Provider<N>,
        N: Network,
    {
        let tx = self.build_transaction_request::<N>(tx_signer);
        provider
            .send_transaction(tx)
            .await
            .map_err(|e| MulticallError::Rpc(Box::new(e)))
    }

    /// Assemble the outer `execute(multicall3, aggregate3Value(...))` tx
    /// request without sending it.
    ///
    /// `tx_signer` is the EOA that will sign the outer transaction; it is
    /// set as the request's `to` (self-call) and, at send time, must be
    /// 7702-delegated to `TridentAccount`. `value` is set to the sum of
    /// per-call values. Gas/fees/nonce are left unset so callers can
    /// inspect, customize, or route the tx through any submission path.
    pub fn build_transaction_request<N: Network>(
        self,
        tx_signer: Address,
    ) -> <N as Network>::TransactionRequest {
        let total = self.total_value();
        let inner = self.aggregate3_calldata();

        let outer = TridentAccount::executeCall {
            target: self.multicall3,
            data: inner,
        }
        .abi_encode();

        <N::TransactionRequest as Default>::default()
            .with_to(tx_signer)
            .with_value(total)
            .with_input(outer)
    }
}

#[cfg(test)]
mod tests {
    use alloy_primitives::address;

    use super::*;
    use crate::{
        deployer,
        eip7702_installer::SelfInstaller,
        test_util::{IWETH9, TestSetup, WETH9_ADDRESS},
    };

    #[test]
    fn aggregate3_calldata_starts_with_correct_selector() {
        let calldata = MulticallBuilder::new()
            .send_value(Address::ZERO, U256::from(1))
            .aggregate3_calldata();
        assert_eq!(&calldata[..4], &Multicall3::aggregate3ValueCall::SELECTOR);
    }

    #[test]
    fn total_value_sums_per_call_values() {
        let builder = MulticallBuilder::new()
            .call(Address::ZERO, &IWETH9::depositCall {})
            .call_with_value(Address::ZERO, &IWETH9::depositCall {}, U256::from(5))
            .send_value(Address::ZERO, U256::from(7));
        assert_eq!(builder.total_value(), U256::from(12));
    }

    #[tokio::test]
    async fn send_executes_multi_step_batch_on_delegated_eoa() {
        let setup = TestSetup::new().await;
        let provider = setup.provider();
        let eoa = setup.signer.address();

        // Deploy TridentAccount, then delegate the EOA to it via EIP-7702.
        let trident_addr = deployer::deploy(provider.clone())
            .await
            .expect("deploy trident");
        SelfInstaller::new()
            .with_target(trident_addr)
            .install(provider.clone(), &setup.signer)
            .await
            .expect("install delegation")
            .get_receipt()
            .await
            .expect("install confirmed");

        // Recipients.
        let bob = address!("00000000000000000000000000000000000000bb");
        let carol = address!("00000000000000000000000000000000000000cc");

        let deposit_amount = U256::from(500_000_000_000_000_000u128); // 0.5 ETH → WETH
        let transfer_amount = U256::from(100_000_000_000_000_000u128); // 0.1 WETH → bob
        let send_amount = U256::from(300_000_000_000_000_000u128); // 0.3 ETH → carol

        let eoa_balance_before = provider.get_balance(eoa).await.expect("balance");
        let carol_balance_before = provider.get_balance(carol).await.expect("balance");

        // Batch: deposit 0.5 ETH → WETH, transfer 0.1 WETH → bob, send 0.3 ETH → carol.
        MulticallBuilder::new()
            .call_with_value(WETH9_ADDRESS, &IWETH9::depositCall {}, deposit_amount)
            .call(
                WETH9_ADDRESS,
                &IWETH9::transferCall {
                    to: bob,
                    amount: transfer_amount,
                },
            )
            .send_value(carol, send_amount)
            .send_with_wallet_provider(provider.clone())
            .await
            .expect("multicall send")
            .get_receipt()
            .await
            .expect("multicall confirmed");

        // WETH balances
        let weth = IWETH9::new(WETH9_ADDRESS, provider.clone());
        let eoa_weth = weth
            .balanceOf(eoa)
            .call()
            .await
            .expect("weth balanceOf eoa");
        let bob_weth = weth
            .balanceOf(bob)
            .call()
            .await
            .expect("weth balanceOf bob");
        assert_eq!(eoa_weth, deposit_amount - transfer_amount);
        assert_eq!(bob_weth, transfer_amount);

        // ETH balances
        let carol_balance_after = provider.get_balance(carol).await.expect("balance");
        assert_eq!(
            carol_balance_after - carol_balance_before,
            send_amount,
            "carol should have received exactly send_amount",
        );

        let eoa_balance_after = provider.get_balance(eoa).await.expect("balance");
        let eoa_spent = eoa_balance_before - eoa_balance_after;
        // EOA spent ≈ 0.5 (locked in WETH) + 0.3 (to carol) + gas. If the
        // self-send of 0.8 ETH were a real cost, spent would be ~1.6 ETH.
        // Bound the upper end to prove the self-send is net zero.
        let expected = deposit_amount + send_amount;
        let gas_ceiling = U256::from(100_000_000_000_000_000u128); // 0.1 ETH gas budget
        assert!(
            eoa_spent >= expected,
            "eoa spent ({eoa_spent}) should be at least deposit+send ({expected})"
        );
        assert!(
            eoa_spent <= expected + gas_ceiling,
            "eoa spent ({eoa_spent}) exceeds deposit+send+gas ceiling — \
             looks like a double-charge, self-send should be net zero"
        );
    }
}