In this tutorial, you’ll deploy the BatchCallAndSponsor contract, simulate an EIP-7702 upgrade of an EOA into a smart account, and execute both direct and sponsored batch calls on a BuildBear sandbox using Foundry and Forge scripts.
You will need to fund your accounts with native tokens to pay for gas and ETH transfers.
You can use the BuildBear native faucet to fund your accounts.
You can generate new keypairs as required with the command below.
Then fund them via the BuildBear faucet.
Please ensure that you have set your EVM version to prague in your foundry.toml file.
This is required for EIP-7702 compatibility and to ensure this tutorial works as expected.
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";/** * @title BatchCallAndSponsor * @notice An educational contract that allows batch execution of calls with nonce and signature verification. * * When an EOA upgrades via EIP‑7702, it delegates to this implementation. * Off‑chain, the account signs a message authorizing a batch of calls. The message is the hash of: * keccak256(abi.encodePacked(nonce, calls)) * The signature must be generated with the EOA’s private key so that, once upgraded, the recovered signer equals the account’s own address (i.e. address(this)). * * This contract provides two ways to execute a batch: * 1. With a signature: Any sponsor can submit the batch if it carries a valid signature. * 2. Directly by the smart account: When the account itself (i.e. address(this)) calls the function, no signature is required. * * Replay protection is achieved by using a nonce that is included in the signed message. */contract BatchCallAndSponsor { using ECDSA for bytes32; /// @notice A nonce used for replay protection. uint256 public nonce; /// @notice Represents a single call within a batch. struct Call { address to; uint256 value; bytes data; } /// @notice Emitted for every individual call executed. event CallExecuted(address indexed sender, address indexed to, uint256 value, bytes data); /// @notice Emitted when a full batch is executed. event BatchExecuted(uint256 indexed nonce, Call[] calls); /** * @notice Executes a batch of calls using an off–chain signature. * @param calls An array of Call structs containing destination, ETH value, and calldata. * @param signature The ECDSA signature over the current nonce and the call data. * * The signature must be produced off–chain by signing: * The signing key should be the account’s key (which becomes the smart account’s own identity after upgrade). */ function execute(Call[] calldata calls, bytes calldata signature) external payable { // Compute the digest that the account was expected to sign. bytes memory encodedCalls; for (uint256 i = 0; i < calls.length; i++) { encodedCalls = abi.encodePacked(encodedCalls, calls[i].to, calls[i].value, calls[i].data); } bytes32 digest = keccak256(abi.encodePacked(nonce, encodedCalls)); bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(digest); // Recover the signer from the provided signature. address recovered = ECDSA.recover(ethSignedMessageHash, signature); require(recovered == address(this), "Invalid signature"); _executeBatch(calls); } /** * @notice Executes a batch of calls directly. * @dev This function is intended for use when the smart account itself (i.e. address(this)) * calls the contract. It checks that msg.sender is the contract itself. * @param calls An array of Call structs containing destination, ETH value, and calldata. */ function execute(Call[] calldata calls) external payable { require(msg.sender == address(this), "Invalid authority"); _executeBatch(calls); } /** * @dev Internal function that handles batch execution and nonce incrementation. * @param calls An array of Call structs. */ function _executeBatch(Call[] calldata calls) internal { uint256 currentNonce = nonce; nonce++; // Increment nonce to protect against replay attacks for (uint256 i = 0; i < calls.length; i++) { _executeCall(calls[i]); } emit BatchExecuted(currentNonce, calls); } /** * @dev Internal function to execute a single call. * @param callItem The Call struct containing destination, value, and calldata. */ function _executeCall(Call calldata callItem) internal { (bool success,) = callItem.to.call{value: callItem.value}(callItem.data); require(success, "Call reverted"); emit CallExecuted(msg.sender, callItem.to, callItem.value, callItem.data); } // Allow the contract to receive ETH (e.g. from DEX swaps or other transfers). fallback() external payable {} receive() external payable {}}
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;import "forge-std/Script.sol";import {Vm} from "forge-std/Vm.sol";import {BatchCallAndSponsor} from "../src/BatchCallAndSponsor.sol";import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";contract MockERC20 is ERC20 { constructor() ERC20("Mock Token", "MOCK") {} function mint(address to, uint256 amount) external { _mint(to, amount); }}contract BatchCallAndSponsorScript is Script { // Alice's address and private key (EOA with no initial contract code). uint256 ALICE_PK = vm.envUint("ALICE_PK"); address payable ALICE_ADDRESS = payable(vm.addr(ALICE_PK)); // Bob's address and private key (Bob will execute transactions on Alice's behalf). uint256 BOB_PK = vm.envUint("BOB_PK"); address payable BOB_ADDRESS = payable(vm.addr(BOB_PK)); address random_receiver = 0xD0F580942a9B3B52FE003348233F2dD859eb1b12; // The contract that Alice will delegate execution to. BatchCallAndSponsor public implementation; // ERC-20 token contract for minting test tokens. MockERC20 public token; function run() external { console.log("Alice's Address:", ALICE_ADDRESS); // 0x1E594012762B6AA8515e0B0d0de3Df2DAbA4C776 console.log("Bob's Address:", BOB_ADDRESS); // 0x92F860dfF64E71025d9e8d798Aff126463e2F618 // Start broadcasting transactions with Alice's private key. vm.startBroadcast(ALICE_PK); // Deploy the delegation contract (Alice will delegate calls to this contract). implementation = new BatchCallAndSponsor(); // Deploy an ERC-20 token contract where Alice is the minter. token = new MockERC20(); token.mint(ALICE_ADDRESS, 1000e18); vm.stopBroadcast(); // Perform direct execution performDirectExecution(); // Perform sponsored execution performSponsoredExecution(); } // Send 1 ETH as well as 100 Mock Tokens to Bob's address. function performDirectExecution() internal { BatchCallAndSponsor.Call[] memory calls = new BatchCallAndSponsor.Call[](2); // ETH transfer calls[0] = BatchCallAndSponsor.Call({to: BOB_ADDRESS, value: 1 ether, data: ""}); // Token transfer calls[1] = BatchCallAndSponsor.Call({ to: address(token), value: 0, data: abi.encodeCall(ERC20.transfer, (BOB_ADDRESS, 100e18)) }); vm.startBroadcast(ALICE_ADDRESS); vm.signAndAttachDelegation(address(implementation), ALICE_PK); BatchCallAndSponsor(ALICE_ADDRESS).execute(calls); vm.stopBroadcast(); console.log("Bob's balance after direct execution:", BOB_ADDRESS.balance); console.log("Bob's token balance after direct execution:", token.balanceOf(BOB_ADDRESS)); } function performSponsoredExecution() internal { console.log("Sending 1 ETH from Alice to a random address, the transaction is sponsored by Bob"); BatchCallAndSponsor.Call[] memory calls = new BatchCallAndSponsor.Call[](1); calls[0] = BatchCallAndSponsor.Call({to: random_receiver, value: 1 ether, data: ""}); vm.startBroadcast(ALICE_PK); // Alice signs a delegation allowing `implementation` to execute transactions on her behalf. Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK); vm.attachDelegation(signedDelegation); vm.stopBroadcast(); // Bob attaches the signed delegation from Alice and broadcasts it. vm.startBroadcast(BOB_PK); // Verify that Alice's account now temporarily behaves as a smart contract. bytes memory code = address(ALICE_ADDRESS).code; require(code.length > 0, "no code written to Alice"); console.log("Code on Alice's account:", vm.toString(code)); bytes memory encodedCalls = ""; for (uint256 i = 0; i < calls.length; i++) { encodedCalls = abi.encodePacked(encodedCalls, calls[i].to, calls[i].value, calls[i].data); } bytes32 digest = keccak256(abi.encodePacked(BatchCallAndSponsor(ALICE_ADDRESS).nonce(), encodedCalls)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, MessageHashUtils.toEthSignedMessageHash(digest)); bytes memory signature = abi.encodePacked(r, s, v); // As Bob, execute the transaction via Alice's temporarily assigned contract. BatchCallAndSponsor(ALICE_ADDRESS).execute(calls, signature); vm.stopBroadcast(); console.log("Recipient balance after sponsored execution:", random_receiver); }}
Direct execution: Alice calls her own contract to send 1 ETH and 100 MOCK to Bob
Sponsored execution: Bob submits Alice’s signed batch to send 1 ETH to a random address
Example output:
== Logs == Alice's Address: 0x1E594012762B6AA8515e0B0d0de3Df2DAbA4C776 Bob's Address: 0x92F860dfF64E71025d9e8d798Aff126463e2F618 Bob's balance after direct execution: 102999883100162512499 Bob's token balance after direct execution: 100000000000000000000 Sending 1 ETH from Alice to a random address, the transaction is sponsored by Bob Code on Alice's account: 0xef0100eb7ed282ee6e967d3450bca8af67d3b87b09ad85 Recipient balance after sponsored execution: 0xD0F580942a9B3B52FE003348233F2dD859eb1b12
To debug and visualize the execution, you can use Sentio. This provides a detailed view of the fund flow, call trace, gas profiler, and more.
You will need to install the Sentio Plugin from BuildBear's Plugin Marketplace