Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7821: Minimal Batch Executor Interface

A minimal batch executor interface for delegations

Authors Vectorized (@Vectorized), Jake Moxey (@jxom), Hadrien Croubois (@Amxx)
Created 2024-11-21
Discussion Link https://ethereum-magicians.org/t/erc-7821-minimal-batch-executor-interface/21776
Requires EIP-7579

Abstract

This proposal defines a minimal batch executor interface for delegations. A delegation is a smart contract that implements logic which other smart contracts can delegate to. This allows batched executions to be prepared in a standardized way.

Motivation

With the advent of EIP-7702, it is possible for Externally Owned Accounts (EOAs) to perform atomic batched executions.

We anticipate that there will be multiple EIP-7702 delegations from multiple major vendors. To enable frontends to detect and prepare a vendor-agnostic batched transaction, we will need a standardized interface for batched executions.

In the absence of such a standard, vendors may choose to create their own proprietary implementations, causing fragmentation. Imagine visiting your favorite decentralized exchange and realizing that their frontend is still incompatible with your EOA delegation. The infamous approve and swap workflow cannot be fixed just by EIP-7702 alone.

We need a standardized batch executor interface.

Hence the utmost motivation for this proposal, which has been crafted for maximal simplicity, extensibility, performance and compatibility.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Overview

The minimal batch executor interface is defined as follows:

/// @dev Interface for minimal batch executor.
interface IERC7821 {
    /// @dev Call struct for the `execute` function.
    struct Call {
        address target; // Replaced with `address(this)` if `address(0)`.
        uint256 value; // Amount of native currency (i.e. Ether) to send.
        bytes data; // Calldata to send with the call.
    }

    /// @dev Executes the calls in `executionData`.
    /// Reverts and bubbles up error if any call fails.
    ///
    /// `executionData` encoding:
    /// - If `opData` is empty, `executionData` is simply `abi.encode(calls)`.
    /// - Else, `executionData` is `abi.encode(calls, opData)`.
    ///   See: https://eips.ethereum.org/EIPS/eip-7579
    ///
    /// Supported modes:
    /// - `bytes32(0x01000000000000000000...)`: does not support optional `opData`.
    /// - `bytes32(0x01000000000078210001...)`: supports optional `opData`.
    ///
    /// Authorization checks:
    /// - If `opData` is empty, the implementation SHOULD require that
    ///   `msg.sender == address(this)`.
    /// - If `opData` is not empty, the implementation SHOULD use the signature
    ///   encoded in `opData` to determine if the caller can perform the execution.
    /// - If `msg.sender` is an authorized entry point, then `execute` MAY accept
    ///   calls from the entry point, and MAY use `opData` for specialized logic.
    ///
    /// `opData` may be used to store additional data for authentication,
    /// paymaster data, gas limits, etc.
    function execute(bytes32 mode, bytes calldata executionData)
        external
        payable;

    /// @dev This function is provided for frontends to detect support.
    /// Only returns true for:
    /// - `bytes32(0x01000000000000000000...)`: does not support optional `opData`.
    /// - `bytes32(0x01000000000078210001...)`: supports optional `opData`.
    function supportsExecutionMode(bytes32 mode) external view returns (bool);
}

Recommendations

To support the approve + swap workflow on EOAs with delegations, frontends SHOULD:

  1. Query supportsExecutionMode(bytes32(0x0100000000000000000000000000000000000000000000000000000000000000)), ensuring that it returns true.

  2. Perform execute(bytes32(0x0100000000000000000000000000000000000000000000000000000000000000), abi.encode(calls)).

Rationale

We aim for radical minimalism to keep the standard as left-curved as possible. Simplicity is the key to adoption. Our North Star is to get every decentralized exchange to support the approve + swap workflow for EOAs with delegations as soon as possible.

execute and supportsExecutionMode

We have opted to use the execute and supportsExecutionMode functions in ERC-7579 for better compatibility with the existing smart account ecosystem.

While radical minimalism is the goal, some compromises have to be made in the pursuit for better adoption.

For minimalism, this standard does not require implementing ERC-165 and the executeFromExecutor function in ERC-7579.

Optional encoding of opData in executionData

The opData bytes parameter can be optionally included in executionData by either doing abi.encode(calls) or abi.encode(calls, opData).

Replacing address(0) with address(this)

For calldata compression optimization.

Backwards Compatibility

No backwards compatibility issues.

Reference Implementation

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.4;

/// @notice Minimal batch executor mixin.
abstract contract ERC7821 {
    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
    /*                          STRUCTS                           */
    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

    /// @dev Call struct for the `execute` function.
    struct Call {
        address target; // Replaced with `address(this)` if `address(0)`.
        uint256 value; // Amount of native currency (i.e. Ether) to send.
        bytes data; // Calldata to send with the call.
    }

    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
    /*                           ERRORS                           */
    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

    /// @dev The execution mode is not supported.
    error UnsupportedExecutionMode();

    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
    /*                    EXECUTION OPERATIONS                    */
    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

    /// @dev Executes the calls in `executionData` and returns the results.
    /// The `results` are the returned data from each call.
    /// Reverts and bubbles up error if any call fails.
    ///
    /// `executionData` encoding:
    /// - If `opData` is empty, `executionData` is simply `abi.encode(calls)`.
    /// - Else, `executionData` is `abi.encode(calls, opData)`.
    ///   See: https://eips.ethereum.org/EIPS/eip-7579
    ///
    /// Supported modes:
    /// - `bytes32(0x01000000000000000000...)`: does not support optional `opData`.
    /// - `bytes32(0x01000000000078210001...)`: supports optional `opData`.
    ///
    /// Authorization checks:
    /// - If `opData` is empty, the implementation SHOULD require that
    ///   `msg.sender == address(this)`.
    /// - If `opData` is not empty, the implementation SHOULD use the signature
    ///   encoded in `opData` to determine if the caller can perform the execution.
    /// - If `msg.sender` is an authorized entry point, then `execute` MAY accept
    ///   calls from the entry point, and MAY use `opData` for specialized logic.
    ///
    /// `opData` may be used to store additional data for authentication,
    /// paymaster data, gas limits, etc.
    function execute(bytes32 mode, bytes calldata executionData)
        public
        payable
        virtual
    {
        uint256 id = _executionModeId(mode);
        if (id == uint256(0)) revert UnsupportedExecutionMode();
        bool tryWithOpData;
        /// @solidity memory-safe-assembly
        assembly {
            let t := gt(calldataload(executionData.offset), 0x3f)
            tryWithOpData := and(eq(id, 2), and(gt(executionData.length, 0x3f), t))
        }
        Call[] memory calls;
        bytes memory opData;
        if (tryWithOpData) {
            (calls, opData) = abi.decode(executionData, (Call[], bytes));
        } else {
            calls = abi.decode(executionData, (Call[]));
        }
        _execute(calls, opData);
    }

    /// @dev Provided for execution mode support detection.
    /// Only returns true for:
    /// - `bytes32(0x01000000000000000000...)`: does not support optional `opData`.
    /// - `bytes32(0x01000000000078210001...)`: supports optional `opData`.
    function supportsExecutionMode(bytes32 mode) public view virtual returns (bool result) {
        return _executionModeId(mode) != 0;
    }

    /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
    /*                      INTERNAL HELPERS                      */
    /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

    /// @dev 0: invalid mode, 1: no `opData` support, 2: with `opData` support.
    function _executionModeId(bytes32 mode) internal view virtual returns (uint256 id) {
        // Only supports atomic batched executions.
        // For the encoding scheme, see: https://eips.ethereum.org/EIPS/eip-7579
        // Bytes Layout:
        // - [0]      ( 1 byte )  `0x01` for batch call.
        // - [1]      ( 1 byte )  `0x00` for revert on any failure.
        // - [2..5]   ( 4 bytes)  Reserved by ERC7579 for future standardization.
        // - [6..8]   ( 4 bytes)  `0x78210001` or `0x00000000`.
        // - [9..31]  (22 bytes)  Unused. Free for use.
        uint256 m = (uint256(mode) >> (22 * 8)) & 0xffff00000000ffffffff;
        if (m == 0x01000000000078210001) id = 2;
        if (m == 0x01000000000000000000) id = 1;
    }

    /// @dev Executes the calls and returns the results.
    /// Reverts and bubbles up error if any call fails.
    function _execute(Call[] memory calls, bytes memory opData)
        internal
        virtual
    {
        // Very basic auth to only allow this contract to be called by itself.
        // Override this function to perform more complex auth with `opData`.
        if (opData.length == uint256(0)) {
            require(msg.sender == address(this));
            // Remember to return `_execute(calls)` when you override this function.
            return _execute(calls);
        }
        revert(); // In your override, replace this with logic to operate on `opData`.
    }

    /// @dev Executes the calls.
    /// Reverts and bubbles up error if any call fails.
    function _execute(Call[] memory calls) internal virtual {
        for (uint256 i; i < calls.length; ++i) {
            Call memory c = calls[i];
            address target = c.target == address(0) ? address(this) : c.target;
        }
    }

    /// @dev Executes the call.
    /// Reverts and bubbles up error if the call fails.
    function _execute(address target, uint256 value, bytes memory data)
        internal
        virtual
    {
        (bool success, bytes memory result) = target.call{value: value}(data);
        if (success) return;
        /// @solidity memory-safe-assembly
        assembly {
            // Bubble up the revert if the call reverts.
            revert(add(result, 0x20), mload(result))
        }
    }
}

Security Considerations

Access controls for execute

Implementations should ensure that execute have the proper access controls.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Vectorized (@Vectorized), Jake Moxey (@jxom), Hadrien Croubois (@Amxx), "ERC-7821: Minimal Batch Executor Interface [DRAFT]," Ethereum Improvement Proposals, no. 7821, November 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7821.