ERC-8000 - Operator contract for non delegated EOAs

Created 2025-07-02
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This standard defines a contract interface that enables externally owned accounts (EOAs) to perform batch call executions via a standard Operator contract, without requiring them to delegate control or convert into smart contract accounts.

Motivation

The ERC-7702 allows EOAs to become powerful smart contract accounts (SCA), which solves many UX issues, like the double approve + transferFrom transactions.
While this new technology is still reaching wider adoption over time, we need a way to improve UX for the users that decide to not have code attached to their EOAs.
This proposal introduces a lightweight, backward-compatible mechanism to enhance UX for such users. By leveraging a standardized Operator contract, EOAs can batch multiple contract calls into a single transaction—assuming the target contracts are compatible (i.e., implement the Operated pattern).

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.

Definitions

It's OPTIONAL but HIGHLY RECOMMENDED to have the Operator contract as a singleton.

Operator

pragma solidity ^0.8.29;

interface IOperator {
    struct Call {
        address target;
        uint256 value;
        bytes callData;
    }

    /// @notice Execute calls
    /// @param calls An array of Call structs
    /// @return returnData An array of bytes containing the responses
    function execute(Call[] calldata calls) external payable returns (bytes[] memory returnData);

    /// @notice The address which initiated the executions
    /// @return sender The actual sender of the calls
    function onBehalfOf() external view returns (address sender);
}

Methods

execute Execute the calls sent by the actual sender.

MUST revert if any of the calls fail.
MUST return data from the calls.

onBehalfOf Used by the target contract to get the actual caller.

MUST return the actual msg.sender when called in the context of a call.
MUST revert when called outside of the context of a call.

Operated

pragma solidity ^0.8.29;

import { Context } from "@openzeppelin/contracts/utils/Context.sol";
import { IOperator } from "./interfaces/IOperator.sol";

/// @title Operated contract
/// @dev Supports calls through the Operator
abstract contract Operated is Context {
    IOperator public immutable operator;

    constructor(address operator_) {
        operator = IOperator(operator_);
    }

    /// @inheritdoc Context
    function _msgSender() internal view virtual override returns (address) {
        if (msg.sender == address(operator)) {
            return operator.onBehalfOf();
        }

        return msg.sender;
    }
}

Any contract can become compatible to execute the batch call by EOA using operator if it extends the Operated contract. The Operated contract overrides _msgSender() to return operator.onBehalfOf() when the call originates from the Operator. This ensures that the target contract recognizes the EOA initiating the batch execution, preserving correct sender context.

This behavior fits well with the usage of the _msgSender() function from ERC-2771.

Methods

_msgSender Returns msg.sender or operator.onBehalfOf()

Rationale

By having a trusted contract (Operator) that may act on behalf of the EOA wallet, this ERC provides batch call capabilities and keeps the EOA as the caller of the target contracts.

Backwards Compatibility

The main limitation of this ERC is that only contracts that implements the Operated logic will be able to receive calls through the Operator.

Reference Implementation

Operator

pragma solidity ^0.8.29;

import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {IOperator} from "./interfaces/IOperator.sol";

/// @title Operator contract
/// @dev Allows standard EOAs to perform batch calls
contract Operator is IOperator, ReentrancyGuardTransient {
    using TransientSlot for *;
    using Address for address;

    // keccak256(abi.encode(uint256(keccak256("operator.actual.sender")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant MSG_SENDER_STORAGE = 0x0de195ebe01a7763c35bcc87968c4e65e5a5ea50f2d7c33bed46c98755a66000;

    modifier setMsgSender() {
        MSG_SENDER_STORAGE.asAddress().tstore(msg.sender);
        _;
        MSG_SENDER_STORAGE.asAddress().tstore(address(0));
    }

    /// @inheritdoc IOperator
    function onBehalfOf() external view returns (address _actualMsgSender) {
        _actualMsgSender = MSG_SENDER_STORAGE.asAddress().tload();
        require(_actualMsgSender != address(0), "outside-call-context");
    }

    /// @inheritdoc IOperator
    function execute(
        Call[] calldata calls_
    ) external payable override nonReentrant setMsgSender returns (bytes[] memory _returnData) {
        uint256 _length = calls_.length;
        _returnData = new bytes[](_length);

        uint256 _sumOfValues;
        Call calldata _call;
        for (uint256 i; i < _length; ) {
            _call = calls_[i];
            uint256 _value = _call.value;
            unchecked {
                _sumOfValues += _value;
            }
            _returnData[i] = _call.target.functionCallWithValue(_call.callData, _value);
            unchecked {
                ++i;
            }
        }

        require(msg.value == _sumOfValues, "value-mismatch");
    }
}

Worth noting that the usage of transient storage (EIP-1153) for storing the msg.sender is highly RECOMMENDED.

Operated

pragma solidity ^0.8.29;

import {Context} from "@openzeppelin/contracts/utils/Context.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IOperator} from "./interfaces/IOperator.sol";
import {Operated} from "./Operated.sol";

/// @title Operated contract
/// @dev Supports calls through the Operator
contract OperatorCompatible is Operated {
    error InsufficientBalance();

    mapping(address => mapping(address => uint256)) public balance;

    constructor(address operator_) Operated(operator_) {}

     function deposit(address token_, uint256 amount_) public payable {
        if (token_ == address(0)) revert InvalidToken();
        address _sender = _msgSender();
        IERC20(token_).transferFrom(_sender, address(this), amount_);
        balance[token_][_sender] += amount_;
    }

    function withdraw(address token_, uint256 amount_) public {
        if (token_ == address(0)) revert InvalidToken();
        address _sender = _msgSender();
        if (balance[token_][_sender] < amount_) revert InsufficientBalance();
        balance[token_][_sender] -= amount_;
        IERC20(token_).transfer(_sender, amount_);
    }
}

Security Considerations

Copyright

Copyright and related rights waived via CC0.