Documentation

EIP-7702 Smart Accounts: Batch Execution & Sponsorship on BuildBear

Deploy and interact with a BatchCallAndSponsor smart-account using EIP-7702 and BuildBear sandbox.

Banner

Introduction

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.

Prerequisites

1. Clone & Configure

You can either use the repository provided by us or create your own. Here we will use the repository provided by us.

To get started, clone the repository and set up your environment variables:

git clone https://github.com/JustUzair/bb-eip-7702.git
cd bb-eip-7702
cp .env.example .env
forge build

Fill in your .env:

BUILDBEAR_RPC=<YOUR_SANDBOX_RPC_URL>
ALICE_PK=<Alice’s private key>
BOB_PK=<Bob’s private key>

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.

cast wallet new

2. Foundry Configuration

Create or verify foundry.toml:

[profile.default]
src         = "src"
out         = "out"
libs        = ["lib"]
evm_version = "prague"
 
[rpc_endpoints]
buildbear = "${BUILDBEAR_RPC}"

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.

Install dependencies:

forge install

Contracts Overview

We use BatchCallAndSponsor.sol, which:

  • Maintains a nonce for replay protection
  • Allows direct execution when msg.sender == address(this)
  • Allows sponsored execution when any relayer submits a valid ECDSA signature by the upgraded account

BatchCallAndSponsor.sol

// SPDX-License-Identifier: MIT
pragma 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 {}
}

3. Deploy & Verify Contracts

Script Overview

// SPDX-License-Identifier: MIT
pragma 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

In this flow, Alice’s EOA temporarily becomes a smart-account contract and submits the batch herself—no off-chain signature required.

  • vm.signAndAttachDelegation writes the contract’s bytecode to Alice’s address.
  • Since msg.sender == address(this), the contract’s no-signature execute(calls) is used.
  • We log Bob’s updated ETH and token balances to verify success.

Here, Bob (the relayer) submits Alice’s pre-signed batch on her behalf—showcasing EIP-7702 sponsorship.

  • Off-chain: Alice uses vm.signDelegation to authorize a batch.
  • On-chain: Bob calls execute(calls, signature), triggering the signature-check branch.
  • This demonstrates true EIP-7702 sponsorship without any external EntryPoint or bundler.

Deployment and Verification with Sourcify

forge script \
script/BatchCallAndSponsor.s.sol:BatchCallAndSponsorScript \
--rpc-url buildbear \
--broadcast \
--verifier sourcify \
--verify \
--verifier-url https://rpc.buildbear.io/verify/sourcify/server/YOUR_RPC_HERE

You can add the above to a Makefile and use it instead:

make deploy-sourcify

4. Execute Batch Calls

The script will:

  1. Deploy BatchCallAndSponsor and MockERC20
  2. Mint 1,000 MOCK to Alice
  3. Direct execution: Alice calls her own contract to send 1 ETH and 100 MOCK to Bob
  4. 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

tx-overview

5. Debug & Visualize with Sentio (Optional)

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

Once you have Sentio installed, you can visit the transaction details page for your transaction

Clicking on "View Trace on Sentio" opens the debugger for:

Fund Flow

Fund Flow

Call Trace

Call Trace

Gas Profiler

Gas Profiler

Events Tab

Events

State Tab

State

Conclusion

You have learned to:

  • Deploy an EIP-7702 smart-account contract with Foundry
  • Execute both self-signed and sponsored batch transactions
  • Verify on-chain via Sourcify and trace executions with Sentio

For the full code and scripts, see the GitHub repository.