Skip to main content

trident_evm_aa/
call_builder.rs

1//! Compose multiple sub-calls into a single transaction against a
2//! 7702-delegated `TridentAccount` EOA via Multicall3.
3
4use alloy_network::{Network, TransactionBuilder};
5use alloy_primitives::{Address, Bytes, U256, address};
6use alloy_provider::{PendingTransactionBuilder, Provider, WalletProvider};
7use alloy_sol_types::SolCall;
8
9use crate::abi::{Multicall3, TridentAccount};
10
11/// Canonical Multicall3 deployment address — same on every major EVM chain.
12const MULTICALL3: Address = address!("cA11bde05977b3631167028862bE2a173976CA11");
13
14/// Errors surfaced by [`MulticallBuilder::send`].
15#[derive(Debug, thiserror::Error)]
16pub enum MulticallError {
17    /// Transport-level RPC failure.
18    #[error("rpc: {0}")]
19    Rpc(Box<dyn std::error::Error + Send + Sync>),
20}
21
22/// Accumulator for a Multicall3 batch to be run via a self-call into a
23/// 7702-delegated `TridentAccount` EOA.
24#[derive(Default, Clone)]
25pub struct MulticallBuilder {
26    calls: Vec<Multicall3::Call3Value>,
27    multicall3: Address,
28}
29
30impl MulticallBuilder {
31    /// New builder with `multicall3` set to the canonical Multicall3 address.
32    pub fn new() -> Self {
33        Self {
34            calls: Vec::new(),
35            multicall3: MULTICALL3,
36        }
37    }
38
39    /// Override the Multicall3 address (tests / non-canonical chains).
40    pub fn with_multicall3(mut self, addr: Address) -> Self {
41        self.multicall3 = addr;
42        self
43    }
44
45    /// Append a zero-value contract call.
46    pub fn call<C: SolCall>(mut self, contract: Address, call: &C) -> Self {
47        self.calls.push(Multicall3::Call3Value {
48            target: contract,
49            allowFailure: false,
50            value: U256::ZERO,
51            callData: call.abi_encode().into(),
52        });
53        self
54    }
55
56    /// Append a value-bearing contract call.
57    pub fn call_with_value<C: SolCall>(mut self, contract: Address, call: &C, value: U256) -> Self {
58        self.calls.push(Multicall3::Call3Value {
59            target: contract,
60            allowFailure: false,
61            value,
62            callData: call.abi_encode().into(),
63        });
64        self
65    }
66
67    /// Append a pure ETH transfer (empty calldata).
68    pub fn send_value(mut self, to: Address, value: U256) -> Self {
69        self.calls.push(Multicall3::Call3Value {
70            target: to,
71            allowFailure: false,
72            value,
73            callData: Bytes::new(),
74        });
75        self
76    }
77
78    /// Sum of per-call values — this is the outer `msg.value` that will be
79    /// sent to satisfy Multicall3's invariant.
80    pub fn total_value(&self) -> U256 {
81        self.calls.iter().map(|c| c.value).sum()
82    }
83
84    /// Inner calldata: ABI-encoded `Multicall3.aggregate3Value(calls)`.
85    pub fn aggregate3_calldata(&self) -> Bytes {
86        Multicall3::aggregate3ValueCall {
87            calls: self.calls.clone(),
88        }
89        .abi_encode()
90        .into()
91    }
92
93    /// Build + send the outer self-call. Convenience wrapper around
94    /// [`send`](Self::send) that uses the provider's default signer as the
95    /// outer-tx signer. That EOA must be 7702-delegated to `TridentAccount`.
96    ///
97    /// Returns an [`alloy_provider::PendingTransactionBuilder`].
98    pub async fn send_with_wallet_provider<P, N>(
99        self,
100        provider: P,
101    ) -> Result<PendingTransactionBuilder<N>, MulticallError>
102    where
103        P: Provider<N> + WalletProvider<N>,
104        N: Network,
105    {
106        let tx_signer = provider.default_signer_address();
107        self.send(provider, tx_signer).await
108    }
109
110    /// Build + send the outer self-call.
111    ///
112    /// `tx_signer` is the EOA that will sign the outer transaction — the
113    /// `from` of the outgoing tx, which is also its `to` because this is a
114    /// self-call. It must be 7702-delegated to `TridentAccount`, and it
115    /// must match the key `provider` is configured to sign with; otherwise
116    /// the provider will fail to sign or the chain will reject the tx.
117    ///
118    /// Returns an [`alloy_provider::PendingTransactionBuilder`].
119    pub async fn send<P, N>(
120        self,
121        provider: P,
122        tx_signer: Address,
123    ) -> Result<PendingTransactionBuilder<N>, MulticallError>
124    where
125        P: Provider<N>,
126        N: Network,
127    {
128        let tx = self.build_transaction_request::<N>(tx_signer);
129        provider
130            .send_transaction(tx)
131            .await
132            .map_err(|e| MulticallError::Rpc(Box::new(e)))
133    }
134
135    /// Assemble the outer `execute(multicall3, aggregate3Value(...))` tx
136    /// request without sending it.
137    ///
138    /// `tx_signer` is the EOA that will sign the outer transaction; it is
139    /// set as the request's `to` (self-call) and, at send time, must be
140    /// 7702-delegated to `TridentAccount`. `value` is set to the sum of
141    /// per-call values. Gas/fees/nonce are left unset so callers can
142    /// inspect, customize, or route the tx through any submission path.
143    pub fn build_transaction_request<N: Network>(
144        self,
145        tx_signer: Address,
146    ) -> <N as Network>::TransactionRequest {
147        let total = self.total_value();
148        let inner = self.aggregate3_calldata();
149
150        let outer = TridentAccount::executeCall {
151            target: self.multicall3,
152            data: inner,
153        }
154        .abi_encode();
155
156        <N::TransactionRequest as Default>::default()
157            .with_to(tx_signer)
158            .with_value(total)
159            .with_input(outer)
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use alloy_primitives::address;
166
167    use super::*;
168    use crate::{
169        deployer,
170        eip7702_installer::SelfInstaller,
171        test_util::{IWETH9, TestSetup, WETH9_ADDRESS},
172    };
173
174    #[test]
175    fn aggregate3_calldata_starts_with_correct_selector() {
176        let calldata = MulticallBuilder::new()
177            .send_value(Address::ZERO, U256::from(1))
178            .aggregate3_calldata();
179        assert_eq!(&calldata[..4], &Multicall3::aggregate3ValueCall::SELECTOR);
180    }
181
182    #[test]
183    fn total_value_sums_per_call_values() {
184        let builder = MulticallBuilder::new()
185            .call(Address::ZERO, &IWETH9::depositCall {})
186            .call_with_value(Address::ZERO, &IWETH9::depositCall {}, U256::from(5))
187            .send_value(Address::ZERO, U256::from(7));
188        assert_eq!(builder.total_value(), U256::from(12));
189    }
190
191    #[tokio::test]
192    async fn send_executes_multi_step_batch_on_delegated_eoa() {
193        let setup = TestSetup::new().await;
194        let provider = setup.provider();
195        let eoa = setup.signer.address();
196
197        // Deploy TridentAccount, then delegate the EOA to it via EIP-7702.
198        let trident_addr = deployer::deploy(provider.clone())
199            .await
200            .expect("deploy trident");
201        SelfInstaller::new()
202            .with_target(trident_addr)
203            .install(provider.clone(), &setup.signer)
204            .await
205            .expect("install delegation")
206            .get_receipt()
207            .await
208            .expect("install confirmed");
209
210        // Recipients.
211        let bob = address!("00000000000000000000000000000000000000bb");
212        let carol = address!("00000000000000000000000000000000000000cc");
213
214        let deposit_amount = U256::from(500_000_000_000_000_000u128); // 0.5 ETH → WETH
215        let transfer_amount = U256::from(100_000_000_000_000_000u128); // 0.1 WETH → bob
216        let send_amount = U256::from(300_000_000_000_000_000u128); // 0.3 ETH → carol
217
218        let eoa_balance_before = provider.get_balance(eoa).await.expect("balance");
219        let carol_balance_before = provider.get_balance(carol).await.expect("balance");
220
221        // Batch: deposit 0.5 ETH → WETH, transfer 0.1 WETH → bob, send 0.3 ETH → carol.
222        MulticallBuilder::new()
223            .call_with_value(WETH9_ADDRESS, &IWETH9::depositCall {}, deposit_amount)
224            .call(
225                WETH9_ADDRESS,
226                &IWETH9::transferCall {
227                    to: bob,
228                    amount: transfer_amount,
229                },
230            )
231            .send_value(carol, send_amount)
232            .send_with_wallet_provider(provider.clone())
233            .await
234            .expect("multicall send")
235            .get_receipt()
236            .await
237            .expect("multicall confirmed");
238
239        // WETH balances
240        let weth = IWETH9::new(WETH9_ADDRESS, provider.clone());
241        let eoa_weth = weth
242            .balanceOf(eoa)
243            .call()
244            .await
245            .expect("weth balanceOf eoa");
246        let bob_weth = weth
247            .balanceOf(bob)
248            .call()
249            .await
250            .expect("weth balanceOf bob");
251        assert_eq!(eoa_weth, deposit_amount - transfer_amount);
252        assert_eq!(bob_weth, transfer_amount);
253
254        // ETH balances
255        let carol_balance_after = provider.get_balance(carol).await.expect("balance");
256        assert_eq!(
257            carol_balance_after - carol_balance_before,
258            send_amount,
259            "carol should have received exactly send_amount",
260        );
261
262        let eoa_balance_after = provider.get_balance(eoa).await.expect("balance");
263        let eoa_spent = eoa_balance_before - eoa_balance_after;
264        // EOA spent ≈ 0.5 (locked in WETH) + 0.3 (to carol) + gas. If the
265        // self-send of 0.8 ETH were a real cost, spent would be ~1.6 ETH.
266        // Bound the upper end to prove the self-send is net zero.
267        let expected = deposit_amount + send_amount;
268        let gas_ceiling = U256::from(100_000_000_000_000_000u128); // 0.1 ETH gas budget
269        assert!(
270            eoa_spent >= expected,
271            "eoa spent ({eoa_spent}) should be at least deposit+send ({expected})"
272        );
273        assert!(
274            eoa_spent <= expected + gas_ceiling,
275            "eoa spent ({eoa_spent}) exceeds deposit+send+gas ceiling — \
276             looks like a double-charge, self-send should be net zero"
277        );
278    }
279}