diff --git a/Cargo.lock b/Cargo.lock index 144c6e07800c..6759a6d2fcfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3701,6 +3701,7 @@ dependencies = [ "alloy-consensus", "alloy-contract", "alloy-dyn-abi", + "alloy-eips", "alloy-json-abi", "alloy-json-rpc", "alloy-primitives", diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 347d738e01ec..d19c776b9916 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -573,6 +573,37 @@ "description": "Status of the transaction, retrieved from the transaction receipt." } ] + }, + { + "name": "SignedDelegation", + "description": "Holds a signed EIP-7702 authorization for an authority account to delegate to an implementation.", + "fields": [ + { + "name": "v", + "ty": "uint8", + "description": "The y-parity of the recovered secp256k1 signature (0 or 1)." + }, + { + "name": "r", + "ty": "bytes32", + "description": "First 32 bytes of the signature." + }, + { + "name": "s", + "ty": "bytes32", + "description": "Second 32 bytes of the signature." + }, + { + "name": "nonce", + "ty": "uint64", + "description": "The current nonce of the authority account at signing time.\n Used to ensure signature can't be replayed after account nonce changes." + }, + { + "name": "implementation", + "ty": "address", + "description": "Address of the contract implementation that will be delegated to.\n Gets encoded into delegation code: 0xef0100 || implementation." + } + ] } ], "cheatcodes": [ @@ -3076,6 +3107,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "attachDelegation", + "description": "Designate the next call as an EIP-7702 transaction", + "declaration": "function attachDelegation(SignedDelegation memory signedDelegation) external;", + "visibility": "external", + "mutability": "", + "signature": "attachDelegation((uint8,bytes32,bytes32,uint64,address))", + "selector": "0x14ae3519", + "selectorBytes": [ + 20, + 174, + 53, + 25 + ] + }, + "group": "scripting", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "blobBaseFee", @@ -8806,6 +8857,26 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "signAndAttachDelegation", + "description": "Sign an EIP-7702 authorization and designate the next call as an EIP-7702 transaction", + "declaration": "function signAndAttachDelegation(address implementation, uint256 privateKey) external returns (SignedDelegation memory signedDelegation);", + "visibility": "external", + "mutability": "", + "signature": "signAndAttachDelegation(address,uint256)", + "selector": "0xc7fa7288", + "selectorBytes": [ + 199, + 250, + 114, + 136 + ] + }, + "group": "scripting", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "signCompact_0", @@ -8886,6 +8957,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "signDelegation", + "description": "Sign an EIP-7702 authorization for delegation", + "declaration": "function signDelegation(address implementation, uint256 privateKey) external returns (SignedDelegation memory signedDelegation);", + "visibility": "external", + "mutability": "", + "signature": "signDelegation(address,uint256)", + "selector": "0x5b593c7b", + "selectorBytes": [ + 91, + 89, + 60, + 123 + ] + }, + "group": "scripting", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "signP256", diff --git a/crates/cheatcodes/spec/src/lib.rs b/crates/cheatcodes/spec/src/lib.rs index 5692dfc48b9c..c4d7e9868fe1 100644 --- a/crates/cheatcodes/spec/src/lib.rs +++ b/crates/cheatcodes/spec/src/lib.rs @@ -87,6 +87,7 @@ impl Cheatcodes<'static> { Vm::Gas::STRUCT.clone(), Vm::DebugStep::STRUCT.clone(), Vm::BroadcastTxSummary::STRUCT.clone(), + Vm::SignedDelegation::STRUCT.clone(), ]), enums: Cow::Owned(vec![ Vm::CallerMode::ENUM.clone(), diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index d703f48e7ae5..bf0fe9781f8a 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -308,6 +308,22 @@ interface Vm { bool success; } + /// Holds a signed EIP-7702 authorization for an authority account to delegate to an implementation. + struct SignedDelegation { + /// The y-parity of the recovered secp256k1 signature (0 or 1). + uint8 v; + /// First 32 bytes of the signature. + bytes32 r; + /// Second 32 bytes of the signature. + bytes32 s; + /// The current nonce of the authority account at signing time. + /// Used to ensure signature can't be replayed after account nonce changes. + uint64 nonce; + /// Address of the contract implementation that will be delegated to. + /// Gets encoded into delegation code: 0xef0100 || implementation. + address implementation; + } + // ======== EVM ======== /// Gets the address for a given private key. @@ -2018,6 +2034,18 @@ interface Vm { #[cheatcode(group = Scripting)] function broadcastRawTransaction(bytes calldata data) external; + /// Sign an EIP-7702 authorization for delegation + #[cheatcode(group = Scripting)] + function signDelegation(address implementation, uint256 privateKey) external returns (SignedDelegation memory signedDelegation); + + /// Designate the next call as an EIP-7702 transaction + #[cheatcode(group = Scripting)] + function attachDelegation(SignedDelegation memory signedDelegation) external; + + /// Sign an EIP-7702 authorization and designate the next call as an EIP-7702 transaction + #[cheatcode(group = Scripting)] + function signAndAttachDelegation(address implementation, uint256 privateKey) external returns (SignedDelegation memory signedDelegation); + /// Returns addresses of available unlocked wallets in the script environment. #[cheatcode(group = Scripting)] function getWallets() external returns (address[] memory wallets); diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 046a495a10e8..6f3acb58c273 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -46,7 +46,10 @@ use revm::{ EOFCreateInputs, EOFCreateKind, Gas, InstructionResult, Interpreter, InterpreterAction, InterpreterResult, }, - primitives::{BlockEnv, CreateScheme, EVMError, EvmStorageSlot, SpecId, EOF_MAGIC_BYTES}, + primitives::{ + BlockEnv, CreateScheme, EVMError, EvmStorageSlot, SignedAuthorization, SpecId, + EOF_MAGIC_BYTES, + }, EvmContext, InnerEvmContext, Inspector, }; use serde_json::Value; @@ -373,6 +376,11 @@ pub struct Cheatcodes { /// execution block environment. pub block: Option, + /// Currently active EIP-7702 delegation that will be consumed when building the next + /// transaction. Set by `vm.attachDelegation()` and consumed via `.take()` during + /// transaction construction. + pub active_delegation: Option, + /// The gas price. /// /// Used in the cheatcode handler to overwrite the gas price separately from the gas price @@ -497,6 +505,7 @@ impl Cheatcodes { labels: config.labels.clone(), config, block: Default::default(), + active_delegation: Default::default(), gas_price: Default::default(), prank: Default::default(), expected_revert: Default::default(), @@ -1014,18 +1023,26 @@ where { let account = ecx.journaled_state.state().get_mut(&broadcast.new_origin).unwrap(); + let mut tx_req = TransactionRequest { + from: Some(broadcast.new_origin), + to: Some(TxKind::from(Some(call.target_address))), + value: call.transfer_value(), + input: TransactionInput::new(call.input.clone()), + nonce: Some(account.info.nonce), + chain_id: Some(ecx.env.cfg.chain_id), + gas: if is_fixed_gas_limit { Some(call.gas_limit) } else { None }, + ..Default::default() + }; + + if let Some(auth_list) = self.active_delegation.take() { + tx_req.authorization_list = Some(vec![auth_list]); + } else { + tx_req.authorization_list = None; + } + self.broadcastable_transactions.push_back(BroadcastableTransaction { rpc: ecx.db.active_fork_url(), - transaction: TransactionRequest { - from: Some(broadcast.new_origin), - to: Some(TxKind::from(Some(call.target_address))), - value: call.transfer_value(), - input: TransactionInput::new(call.input.clone()), - nonce: Some(account.info.nonce), - gas: if is_fixed_gas_limit { Some(call.gas_limit) } else { None }, - ..Default::default() - } - .into(), + transaction: tx_req.into(), }); debug!(target: "cheatcodes", tx=?self.broadcastable_transactions.back().unwrap(), "broadcastable call"); diff --git a/crates/cheatcodes/src/script.rs b/crates/cheatcodes/src/script.rs index 29a804efd7ec..3e1239237e83 100644 --- a/crates/cheatcodes/src/script.rs +++ b/crates/cheatcodes/src/script.rs @@ -2,10 +2,13 @@ use crate::{Cheatcode, CheatsCtxt, Result, Vm::*}; use alloy_primitives::{Address, B256, U256}; +use alloy_rpc_types::Authorization; +use alloy_signer::{Signature, SignerSync}; use alloy_signer_local::PrivateKeySigner; use alloy_sol_types::SolValue; use foundry_wallets::{multi_wallet::MultiWallet, WalletSigner}; use parking_lot::Mutex; +use revm::primitives::{Bytecode, SignedAuthorization}; use std::sync::Arc; impl Cheatcode for broadcast_0Call { @@ -29,6 +32,92 @@ impl Cheatcode for broadcast_2Call { } } +impl Cheatcode for attachDelegationCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { signedDelegation } = self; + let SignedDelegation { v, r, s, nonce, implementation } = signedDelegation; + + let auth = Authorization { + address: *implementation, + nonce: *nonce, + chain_id: ccx.ecx.env.cfg.chain_id, + }; + let signed_auth = SignedAuthorization::new_unchecked( + auth, + *v, + U256::from_be_bytes(r.0), + U256::from_be_bytes(s.0), + ); + write_delegation(ccx, signed_auth.clone())?; + ccx.state.active_delegation = Some(signed_auth); + Ok(Default::default()) + } +} + +impl Cheatcode for signDelegationCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { implementation, privateKey } = self; + let signer = PrivateKeySigner::from_bytes(&B256::from(*privateKey))?; + let authority = signer.address(); + let (auth, nonce) = create_auth(ccx, *implementation, authority)?; + let sig = signer.sign_hash_sync(&auth.signature_hash())?; + Ok(sig_to_delegation(sig, nonce, *implementation).abi_encode()) + } +} + +impl Cheatcode for signAndAttachDelegationCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + let Self { implementation, privateKey } = self; + let signer = PrivateKeySigner::from_bytes(&B256::from(*privateKey))?; + let authority = signer.address(); + let (auth, nonce) = create_auth(ccx, *implementation, authority)?; + let sig = signer.sign_hash_sync(&auth.signature_hash())?; + let signed_auth = sig_to_auth(sig, auth); + write_delegation(ccx, signed_auth.clone())?; + ccx.state.active_delegation = Some(signed_auth); + Ok(sig_to_delegation(sig, nonce, *implementation).abi_encode()) + } +} + +fn create_auth( + ccx: &mut CheatsCtxt, + implementation: Address, + authority: Address, +) -> Result<(Authorization, u64)> { + let authority_acc = ccx.ecx.journaled_state.load_account(authority, &mut ccx.ecx.db)?; + let nonce = authority_acc.data.info.nonce; + Ok(( + Authorization { address: implementation, nonce, chain_id: ccx.ecx.env.cfg.chain_id }, + nonce, + )) +} + +fn write_delegation(ccx: &mut CheatsCtxt, auth: SignedAuthorization) -> Result<()> { + let authority = auth.recover_authority().map_err(|e| format!("{e}"))?; + let authority_acc = ccx.ecx.journaled_state.load_account(authority, &mut ccx.ecx.db)?; + if authority_acc.data.info.nonce != auth.nonce { + return Err("invalid nonce".into()); + } + authority_acc.data.info.nonce += 1; + let bytecode = Bytecode::new_eip7702(*auth.address()); + ccx.ecx.journaled_state.set_code(authority, bytecode); + Ok(()) +} + +fn sig_to_delegation(sig: Signature, nonce: u64, implementation: Address) -> SignedDelegation { + SignedDelegation { + v: sig.v().y_parity() as u8, + r: sig.r().into(), + s: sig.s().into(), + nonce, + implementation, + } +} + +fn sig_to_auth(sig: Signature, auth: Authorization) -> SignedAuthorization { + SignedAuthorization::new_unchecked(auth, sig.v().y_parity() as u8, sig.r(), sig.s()) +} + impl Cheatcode for startBroadcast_0Call { fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { let Self {} = self; diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 033c53e49dba..585e0080cc87 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -20,6 +20,7 @@ foundry-config.workspace = true alloy-contract.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } +alloy-eips.workspace = true alloy-json-abi.workspace = true alloy-json-rpc.workspace = true alloy-primitives = { workspace = true, features = [ diff --git a/crates/common/src/transactions.rs b/crates/common/src/transactions.rs index 4ddef26dd03f..f0ed0b54f8bd 100644 --- a/crates/common/src/transactions.rs +++ b/crates/common/src/transactions.rs @@ -1,6 +1,7 @@ //! Wrappers for transactions. use alloy_consensus::{Transaction, TxEnvelope}; +use alloy_eips::eip7702::SignedAuthorization; use alloy_primitives::{Address, TxKind, U256}; use alloy_provider::{ network::{AnyNetwork, ReceiptResponse, TransactionBuilder}, @@ -226,6 +227,14 @@ impl TransactionMaybeSigned { Self::Unsigned(tx) => tx.nonce, } } + + pub fn authorization_list(&self) -> Option> { + match self { + Self::Signed { tx, .. } => tx.authorization_list().map(|auths| auths.to_vec()), + Self::Unsigned(tx) => tx.authorization_list.as_deref().map(|auths| auths.to_vec()), + } + .filter(|auths| !auths.is_empty()) + } } impl From for TransactionMaybeSigned { diff --git a/crates/evm/evm/src/executors/mod.rs b/crates/evm/evm/src/executors/mod.rs index 6f458258a5ea..b5b31b812298 100644 --- a/crates/evm/evm/src/executors/mod.rs +++ b/crates/evm/evm/src/executors/mod.rs @@ -31,8 +31,8 @@ use revm::{ db::{DatabaseCommit, DatabaseRef}, interpreter::{return_ok, InstructionResult}, primitives::{ - BlockEnv, Bytecode, Env, EnvWithHandlerCfg, ExecutionResult, Output, ResultAndState, - SpecId, TxEnv, TxKind, + AuthorizationList, BlockEnv, Bytecode, Env, EnvWithHandlerCfg, ExecutionResult, Output, + ResultAndState, SignedAuthorization, SpecId, TxEnv, TxKind, }, }; use std::borrow::Cow; @@ -378,6 +378,21 @@ impl Executor { self.call_with_env(env) } + /// Performs a raw call to an account on the current state of the VM with an EIP-7702 + /// authorization list. + pub fn call_raw_with_authorization( + &mut self, + from: Address, + to: Address, + calldata: Bytes, + value: U256, + authorization_list: Vec, + ) -> eyre::Result { + let mut env = self.build_test_env(from, to.into(), calldata, value); + env.tx.authorization_list = Some(AuthorizationList::Signed(authorization_list)); + self.call_with_env(env) + } + /// Performs a raw call to an account on the current state of the VM. pub fn transact_raw( &mut self, diff --git a/crates/script/src/runner.rs b/crates/script/src/runner.rs index a7d950690b92..fa8ff19d9a9d 100644 --- a/crates/script/src/runner.rs +++ b/crates/script/src/runner.rs @@ -1,5 +1,6 @@ use super::ScriptResult; use crate::build::ScriptPredeployLibraries; +use alloy_eips::eip7702::SignedAuthorization; use alloy_primitives::{Address, Bytes, TxKind, U256}; use alloy_rpc_types::TransactionRequest; use eyre::Result; @@ -223,7 +224,7 @@ impl ScriptRunner { /// Executes the method that will collect all broadcastable transactions. pub fn script(&mut self, address: Address, calldata: Bytes) -> Result { - self.call(self.evm_opts.sender, address, calldata, U256::ZERO, false) + self.call(self.evm_opts.sender, address, calldata, U256::ZERO, None, false) } /// Runs a broadcastable transaction locally and persists its state. @@ -233,9 +234,17 @@ impl ScriptRunner { to: Option
, calldata: Option, value: Option, + authorization_list: Option>, ) -> Result { if let Some(to) = to { - self.call(from, to, calldata.unwrap_or_default(), value.unwrap_or(U256::ZERO), true) + self.call( + from, + to, + calldata.unwrap_or_default(), + value.unwrap_or(U256::ZERO), + authorization_list, + true, + ) } else if to.is_none() { let res = self.executor.deploy( from, @@ -282,9 +291,20 @@ impl ScriptRunner { to: Address, calldata: Bytes, value: U256, + authorization_list: Option>, commit: bool, ) -> Result { - let mut res = self.executor.call_raw(from, to, calldata.clone(), value)?; + let mut res = if let Some(authorization_list) = authorization_list { + self.executor.call_raw_with_authorization( + from, + to, + calldata.clone(), + value, + authorization_list, + )? + } else { + self.executor.call_raw(from, to, calldata.clone(), value)? + }; let mut gas_used = res.gas_used; // We should only need to calculate realistic gas costs when preparing to broadcast diff --git a/crates/script/src/simulate.rs b/crates/script/src/simulate.rs index a9798c43e471..95427225f202 100644 --- a/crates/script/src/simulate.rs +++ b/crates/script/src/simulate.rs @@ -112,10 +112,10 @@ impl PreSimulationState { // Executes all transactions from the different forks concurrently. let futs = transactions .into_iter() - .map(|transaction| async { + .map(|mut transaction| async { let mut runner = runners.get(&transaction.rpc).expect("invalid rpc url").write(); + let tx = transaction.tx_mut(); - let tx = transaction.tx(); let to = if let Some(TxKind::Call(to)) = tx.to() { Some(to) } else { None }; let result = runner .simulate( @@ -124,6 +124,7 @@ impl PreSimulationState { to, tx.input().map(Bytes::copy_from_slice), tx.value(), + tx.authorization_list(), ) .wrap_err("Internal EVM error during simulation")?; diff --git a/testdata/cheats/Vm.sol b/testdata/cheats/Vm.sol index 2004c44563d0..9d1d3efd1420 100644 --- a/testdata/cheats/Vm.sol +++ b/testdata/cheats/Vm.sol @@ -23,6 +23,7 @@ interface Vm { struct Gas { uint64 gasLimit; uint64 gasTotalUsed; uint64 gasMemoryUsed; int64 gasRefunded; uint64 gasRemaining; } struct DebugStep { uint256[] stack; bytes memoryInput; uint8 opcode; uint64 depth; bool isOutOfGas; address contractAddr; } struct BroadcastTxSummary { bytes32 txHash; BroadcastTxType txType; address contractAddress; uint64 blockNumber; bool success; } + struct SignedDelegation { uint8 v; bytes32 r; bytes32 s; uint64 nonce; address implementation; } function _expectCheatcodeRevert() external; function _expectCheatcodeRevert(bytes4 revertData) external; function _expectCheatcodeRevert(bytes calldata revertData) external; @@ -148,6 +149,7 @@ interface Vm { function assertTrue(bool condition, string calldata error) external pure; function assume(bool condition) external pure; function assumeNoRevert() external pure; + function attachDelegation(SignedDelegation memory signedDelegation) external; function blobBaseFee(uint256 newBlobBaseFee) external; function blobhashes(bytes32[] calldata hashes) external; function breakpoint(string calldata char) external pure; @@ -434,10 +436,12 @@ interface Vm { function setEnv(string calldata name, string calldata value) external; function setNonce(address account, uint64 newNonce) external; function setNonceUnsafe(address account, uint64 newNonce) external; + function signAndAttachDelegation(address implementation, uint256 privateKey) external returns (SignedDelegation memory signedDelegation); function signCompact(Wallet calldata wallet, bytes32 digest) external returns (bytes32 r, bytes32 vs); function signCompact(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); function signCompact(bytes32 digest) external pure returns (bytes32 r, bytes32 vs); function signCompact(address signer, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); + function signDelegation(address implementation, uint256 privateKey) external returns (SignedDelegation memory signedDelegation); function signP256(uint256 privateKey, bytes32 digest) external pure returns (bytes32 r, bytes32 s); function sign(Wallet calldata wallet, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s); function sign(uint256 privateKey, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); diff --git a/testdata/default/cheats/AttachDelegation.t.sol b/testdata/default/cheats/AttachDelegation.t.sol new file mode 100644 index 000000000000..7befc9a32047 --- /dev/null +++ b/testdata/default/cheats/AttachDelegation.t.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity 0.8.18; + +import "ds-test/test.sol"; +import "cheats/Vm.sol"; + +contract AttachDelegationTest is DSTest { + event ExecutedBy(uint256 id); + + Vm constant vm = Vm(HEVM_ADDRESS); + uint256 alice_pk = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + address payable alice = payable(0x70997970C51812dc3A010C7d01b50e0d17dc79C8); + uint256 bob_pk = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; + address bob = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + + SimpleDelegateContract implementation; + SimpleDelegateContract implementation2; + ERC20 token; + + function setUp() public { + implementation = new SimpleDelegateContract(1); + implementation2 = new SimpleDelegateContract(2); + token = new ERC20(alice); + } + + function testCallSingleAttachDelegation() public { + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), alice_pk); + SimpleDelegateContract.Call[] memory calls = new SimpleDelegateContract.Call[](1); + bytes memory data = abi.encodeCall(ERC20.mint, (100, bob)); + calls[0] = SimpleDelegateContract.Call({to: address(token), data: data, value: 0}); + // executing as bob to make clear that we don't need to execute the tx as alice + vm.broadcast(bob_pk); + vm.attachDelegation(signedDelegation); + + bytes memory code = address(alice).code; + require(code.length > 0, "no code written to alice"); + SimpleDelegateContract(alice).execute(calls); + + assertEq(token.balanceOf(bob), 100); + } + + function testMultiCallAttachDelegation() public { + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), alice_pk); + vm.broadcast(bob_pk); + vm.attachDelegation(signedDelegation); + + SimpleDelegateContract.Call[] memory calls = new SimpleDelegateContract.Call[](2); + calls[0] = + SimpleDelegateContract.Call({to: address(token), data: abi.encodeCall(ERC20.mint, (50, bob)), value: 0}); + calls[1] = SimpleDelegateContract.Call({ + to: address(token), + data: abi.encodeCall(ERC20.mint, (50, address(this))), + value: 0 + }); + + SimpleDelegateContract(alice).execute(calls); + + assertEq(token.balanceOf(bob), 50); + assertEq(token.balanceOf(address(this)), 50); + } + + function testSwitchAttachDelegation() public { + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), alice_pk); + + SimpleDelegateContract.Call[] memory calls = new SimpleDelegateContract.Call[](1); + bytes memory data = abi.encodeCall(ERC20.mint, (100, bob)); + calls[0] = SimpleDelegateContract.Call({to: address(token), data: data, value: 0}); + + vm.broadcast(bob_pk); + vm.attachDelegation(signedDelegation); + + vm.expectEmit(true, true, true, true); + emit ExecutedBy(1); + SimpleDelegateContract(alice).execute(calls); + + // switch to implementation2 + Vm.SignedDelegation memory signedDelegation2 = vm.signDelegation(address(implementation2), alice_pk); + vm.broadcast(bob_pk); + vm.attachDelegation(signedDelegation2); + + vm.expectEmit(true, true, true, true); + emit ExecutedBy(2); + SimpleDelegateContract(alice).execute(calls); + + // verify final state + assertEq(token.balanceOf(bob), 200); + } + + function testAttachDelegationRevertInvalidSignature() public { + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), alice_pk); + // change v from 1 to 0 + signedDelegation.v = (signedDelegation.v + 1) % 2; + vm.attachDelegation(signedDelegation); + + SimpleDelegateContract.Call[] memory calls = new SimpleDelegateContract.Call[](1); + bytes memory data = abi.encodeCall(ERC20.mint, (100, bob)); + calls[0] = SimpleDelegateContract.Call({to: address(token), data: data, value: 0}); + + vm.broadcast(alice_pk); + // empty revert because no bytecode was set to Alice's account + vm.expectRevert(); + SimpleDelegateContract(alice).execute(calls); + } + + function testAttachDelegationRevertsAfterNonceChange() public { + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), alice_pk); + + vm.broadcast(alice_pk); + // send tx to increment alice's nonce + token.mint(1, bob); + + vm.expectRevert("vm.attachDelegation: invalid nonce"); + vm.attachDelegation(signedDelegation); + } + + function testCallSingleSignAndAttachDelegation() public { + SimpleDelegateContract.Call[] memory calls = new SimpleDelegateContract.Call[](1); + bytes memory data = abi.encodeCall(ERC20.mint, (100, bob)); + calls[0] = SimpleDelegateContract.Call({to: address(token), data: data, value: 0}); + vm.signAndAttachDelegation(address(implementation), alice_pk); + bytes memory code = address(alice).code; + require(code.length > 0, "no code written to alice"); + vm.broadcast(bob_pk); + SimpleDelegateContract(alice).execute(calls); + + assertEq(token.balanceOf(bob), 100); + } +} + +contract SimpleDelegateContract { + event Executed(address indexed to, uint256 value, bytes data); + event ExecutedBy(uint256 id); + + struct Call { + bytes data; + address to; + uint256 value; + } + + uint256 public immutable id; + + constructor(uint256 _id) { + id = _id; + } + + function execute(Call[] memory calls) external payable { + for (uint256 i = 0; i < calls.length; i++) { + Call memory call = calls[i]; + (bool success, bytes memory result) = call.to.call{value: call.value}(call.data); + require(success, string(result)); + emit Executed(call.to, call.value, call.data); + emit ExecutedBy(id); + } + } + + receive() external payable {} +} + +contract ERC20 { + address public minter; + mapping(address => uint256) private _balances; + + constructor(address _minter) { + minter = _minter; + } + + function mint(uint256 amount, address to) public { + _mint(to, amount); + } + + function balanceOf(address account) public view returns (uint256) { + return _balances[account]; + } + + function _mint(address account, uint256 amount) internal { + require(msg.sender == minter, "ERC20: msg.sender is not minter"); + require(account != address(0), "ERC20: mint to the zero address"); + unchecked { + _balances[account] += amount; + } + } +}