EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8335: Account-Level Transfer With Authorization

An account-level interface to authorize ERC-20 and native asset transfers via off-chain signatures, without token-level support

Authors
Created2026-07-10
Discussion Linkhttps://ethereum-magicians.org/t/erc-8335-account-level-transfer-with-authorization/28977
Requires

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#1869PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationEventConstantsFunctionsSignature verificationExecutionAuthorization nonce managementUse with web3 providersSettlementInterface detectionRationaleWhy existing solutions are insufficientPermit2UserOperation (ERC-4337)ERC-7710Native asset supportEIP-712 domain separatorAuthorization nonce design: random and isolatedfrom in the signed data but not in the function parametersBackwards CompatibilityReference ImplementationAccountTransferAuthorization.solExample: ECDSA owner accountExample: modular account with validator routingSecurity ConsiderationsFront-running protectionReplay protectionSmart account upgrade safetyNative asset reentrancyPhishing risksCopyright

Abstract

A set of functions to enable meta-transactions and atomic interactions with any ERC-20 token or native asset via off-chain signatures, implemented at the smart contract account level rather than the token contract level, conforming to the EIP-712 typed message signing specification.

This ERC defines a standard interface on the smart contract account that enables ERC-3009-style authorized transfers for any ERC-20 token and native assets. A third-party relayer can call this interface to execute a transfer on behalf of the account owner, using only an off-chain signature. No prior on-chain approval or token-level support is needed.

Motivation

Micropayment use cases are emerging in which AI agents pay for API calls and autonomous services settle machine-to-machine transactions. These use cases need high-frequency, low-value token transfers where per-transaction friction stays negligible relative to the payment value.

ERC-3009 is the closest existing fit for this settlement pattern: no setup, a single contract call, no gas cost for the payer, and a small payload. However, ERC-3009 requires each token contract to individually implement the interface. As of 2026, only a handful of tokens support it (most notably USDC and EURC), and tokens already deployed cannot be upgraded to add support. ERC-3009 also cannot support native asset transfers.

Existing alternatives each carry overhead disproportionate to micropayment values: Permit2 enshrines secp256k1 signatures and delegates spending authority to a custodial intermediary; ERC-4337 UserOperations carry material gas overhead and require bundler infrastructure; ERC-7710 delegations route every payment through a heavyweight DelegationManager and require pre-established relationships with known counterparties. See the Rationale section for detailed comparisons.

Smart contract accounts are programmable and can implement arbitrary verification and execution logic. A standard interface on the smart account that mirrors ERC-3009's semantics at the account level gives the same lightweight settlement model to every ERC-20 token and to native assets, with no changes to token contracts.

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.

Event

event TransferAuthorizationUsed(
    address indexed token,
    address indexed from,
    address indexed to,
    uint256 value,
    bytes32 authorizationNonce
);

Constants

/// @notice The ERC-7528 native asset address, representing the chain's native asset (ETH on Ethereum).
address constant NATIVE_ASSET = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

Functions

/**
 * @notice Returns the state of a transfer authorization nonce.
 * @param authorizationNonce  The authorization nonce to check
 * @return                    True if the authorization nonce has been used or canceled
 * @dev    MUST return the used/canceled state tracked internally
 *         (see Authorization Nonce Management).
 */
function transferAuthorizationState(
    bytes32 authorizationNonce
) external view returns (bool);

/**
 * @notice Execute a transfer of any ERC-20 token or native asset with a signed authorization.
 * @dev    MUST verify the signature against the account's owner(s).
 *         The EIP-712 signed data MUST include `from` (= address(this)), but it is not
 *         passed as a function parameter; the contract uses address(this) directly.
 *         MUST verify the authorizationNonce has not been used.
 *         MUST verify block.timestamp is within (validAfter, validBefore).
 *         MUST transfer `value` of `token` to `to` (NATIVE_ASSET denotes the native asset)
 *         as a single atomic transfer; the transfer mechanism is left to the
 *         implementation (see Execution).
 *         MUST mark the authorizationNonce as used.
 *         MUST emit TransferAuthorizationUsed.
 *         MUST revert if any check fails.
 *
 *         Any address MAY call this function (the caller is the relayer/facilitator).
 */
function executeTransferWithAuthorization(
    address token,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 authorizationNonce,
    bytes calldata signature
) external;

/**
 * @notice Execute a transfer with a signed authorization, callable only by the payee.
 * @dev    Behaves exactly like executeTransferWithAuthorization, with one additional
 *         check: MUST verify that msg.sender == to. This lets a payee contract pull the
 *         payment atomically within its own call, so a front-runner cannot trigger the
 *         transfer outside the payee's execution flow.
 *         The authorization MUST be signed under the ReceiveWithAuthorization type hash,
 *         so it cannot be replayed through executeTransferWithAuthorization.
 *         MUST emit TransferAuthorizationUsed.
 *         MUST revert if any check fails.
 */
function receiveWithAuthorization(
    address token,
    address to,
    uint256 value,
    uint256 validAfter,
    uint256 validBefore,
    bytes32 authorizationNonce,
    bytes calldata signature
) external;

Optional:

// keccak256("CancelTransferAuthorization(bytes32 authorizationNonce)")
bytes32 constant CANCEL_TRANSFER_AUTHORIZATION_TYPEHASH =
    0xf30be15aedf9b01d0dac5525241af3753865a4968ffb9fb1a7ad6d2553d29f8e;

event TransferAuthorizationCanceled(
    bytes32 indexed authorizationNonce
);

/**
 * @notice Cancel an unused transfer authorization.
 * @dev    If signature is non-empty, MUST verify the signature against the account's owner(s).
 *         If signature is empty (length 0), MUST verify that msg.sender == address(this),
 *         allowing cancellation via the account's own execution path without redundant signing.
 *         MUST mark the authorizationNonce as used.
 *         MUST emit TransferAuthorizationCanceled.
 */
function cancelTransferAuthorization(
    bytes32 authorizationNonce,
    bytes calldata signature
) external;

The authorization signature is obtained using the EIP-712 typed message signing spec. The domain separator is derived from the account's EIP-712 domain (name, version, chainId, verifyingContract); clients can reconstruct it off-chain from these fields. The signing digest is derived from the domain separator, the type hash that identifies the operation, and the parameter values:

Each operation has a fixed type hash, defined as the keccak256 of its EIP-712 type string. These values are constant and MUST be used as specified:

// keccak256("ExecuteTransferWithAuthorization(address token,address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 authorizationNonce)")
bytes32 constant EXECUTE_TRANSFER_WITH_AUTHORIZATION_TYPEHASH =
    0xe751bf1b144414a77b82ede1d2a433edc347fef283ab5e53e96f438392517b3c;

// keccak256("ReceiveWithAuthorization(address token,address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 authorizationNonce)")
bytes32 constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH =
    0xd8a04c474fcb45b6fb4b17a80506c180af4818903f1ace6c1ff59053338529fd;
// "‖" denotes concatenation
Digest := Keccak256(
    0x1901 ‖ DomainSeparator ‖ Keccak256(ABIEncode(TypeHash, Params...))
)

Signature verification

The smart account MUST verify the signature according to its own authentication logic. The verification mechanism is intentionally left to the account implementation. An EOA-backed account (e.g., via EIP-7702) MAY use ecrecover to verify ECDSA signatures. A smart account with custom signing MUST verify the signature using its internal authentication logic (passkeys, multisig, session keys, etc.); it MAY additionally implement ERC-1271 for external signature verification, but this ERC does not require that. The signature parameter is intentionally bytes calldata to accommodate all signature formats.

Execution

Upon successful signature verification and all validity checks, the smart account MUST transfer value of token to to in a single atomic transfer, reverting the entire transaction if the transfer does not succeed in full. The transfer mechanism is left to the implementation.

Example:

if (token == NATIVE_ASSET) {
    (bool success, ) = to.call{value: value}("");
    require(success);
} else {
    // SafeERC20 handles non-compliant tokens (e.g. USDT) that do not return a bool
    IERC20(token).safeTransfer(to, value);
}

Authorization nonce management

  • Authorization nonces MUST be randomly generated 32-byte values (consistent with ERC-3009's design).
  • Each authorization nonce MUST only be usable once per account.
  • An authorization nonce that has been canceled MUST be treated as used.
  • Implementations MUST track the used/canceled state of each authorization nonce (e.g., via a mapping(bytes32 => bool)).
  • The authorization nonce space used by this ERC MUST be independent from the account's native transaction nonce (e.g., the ERC-4337 EntryPoint nonce, or any execution nonce used by the account framework). Implementations MUST NOT reuse or share nonce state with other account operations.

Use with web3 providers

The signature for an authorization can be obtained using a web3 provider with the eth_signTypedData_v4 method.

Example:

const data = {
  types: {
    EIP712Domain: [
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "chainId", type: "uint256" },
      { name: "verifyingContract", type: "address" },
    ],
    ExecuteTransferWithAuthorization: [
      { name: "token", type: "address" },
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "authorizationNonce", type: "bytes32" },
    ],
  },
  domain: {
    name: smartAccountDomainName, // Account implementation-defined (e.g., "ExampleAccount")
    version: smartAccountDomainVersion, // Account implementation-defined (e.g., "1")
    chainId: selectedChainId,
    verifyingContract: smartAccountAddress, // The smart account's own address
  },
  primaryType: "ExecuteTransferWithAuthorization",
  message: {
    token: tokenAddress, // Any ERC-20 token, or NATIVE_ASSET for native asset
    from: smartAccountAddress, // MUST equal the smart account address (not a function param, but signed)
    to: recipientAddress,
    value: amountBN.toString(10),
    validAfter: 0,
    validBefore: Math.floor(Date.now() / 1000) + 3600, // Valid for 1 hour
    authorizationNonce: ethers.hexlify(ethers.randomBytes(32)),
  },
};

const signature = await provider.send("eth_signTypedData_v4", [
  ownerAddress,
  JSON.stringify(data),
]);

To sign an authorization for receiveWithAuthorization, use primaryType: "ReceiveWithAuthorization" (and the correspondingly named type in types); all message fields are identical. The distinct primary type ensures an authorization intended for atomic receipt cannot be replayed through executeTransferWithAuthorization.

Settlement

The facilitator settles a payment by calling executeTransferWithAuthorization on the smart account with a single transaction:

IAccountTransferAuthorization(smartAccountAddress).executeTransferWithAuthorization(
    token,
    to,
    value,
    validAfter,
    validBefore,
    authorizationNonce,
    signature
);

Facilitators SHOULD verify the authorization will succeed before submitting by simulating the call via eth_call. If the simulation succeeds, the signature is valid, the balance is sufficient, and the authorization nonce is unused.

A payee that is a contract and needs the payment to land atomically within its own logic SHOULD instead expose a function that calls receiveWithAuthorization on the smart account. Because receiveWithAuthorization requires msg.sender == to, only the payee can settle the transfer, so it cannot be front-run outside the payee's execution flow.

Interface detection

Smart accounts that implement this ERC SHOULD also implement ERC-165 and return true for the interface ID of IAccountTransferAuthorization.

The interface ID is computed over the REQUIRED functions only: executeTransferWithAuthorization, receiveWithAuthorization, and transferAuthorizationState. The OPTIONAL function (cancelTransferAuthorization) is excluded, so an implementation that omits it still reports the same interface ID. Implementations MAY expose an additional ERC-165 interface ID for the optional function if they support it.

// ERC-165 ID = executeTransferWithAuthorization.selector ^ receiveWithAuthorization.selector ^ transferAuthorizationState.selector
bytes4 constant INTERFACE_ID = 0x305aa238; // 0x0e720282 ^ 0x88b7ab63 ^ 0xb69f0bd9

Rationale

Why existing solutions are insufficient

Several existing mechanisms enable third-party-submitted token transfers on EVM. Each falls short for lightweight, ad-hoc micropayment settlement from smart accounts.

Permit2

Permit2 uses (v, r, s) signatures, enshrining ECDSA over secp256k1. This is not future-proof as smart accounts adopt alternative schemes (passkeys/P-256, BLS, post-quantum). All approved tokens are also delegated to a single custodial proxy contract, which is a systemic trust dependency and a single point of failure.

UserOperation (ERC-4337)

ERC-4337 UserOperations are a valid general-purpose execution mechanism. This ERC provides a complementary, specialized fast path:

  1. Gas and payload overhead. Published benchmarks show ERC-20 transfers cost about 175k to 215k gas through the EntryPoint versus about 51k direct (Alchemy aa-benchmarks, Optimism Mainnet; account- and chain-dependent). UserOp payloads contain 11+ fields versus about 8 for this ERC.
  2. Nonce model. ERC-4337's keyed nonces allow parallel channels but require key allocation and sequence tracking. Random bytes32 authorization nonces remove the need for coordination.
  3. Verification and decoupling. Facilitators verify via eth_call simulation, the same pattern used for ERC-3009. This ERC does not depend on ERC-4337 infrastructure, so any address can act as a facilitator, and smart accounts with alternative execution models can implement it.

ERC-7710

ERC-7710 shares ERC-4337's cost structure: every payment routes through a heavyweight general-purpose entrypoint. The DelegationManager parses the delegation object, validates the delegation chain, iterates caveat enforcers, and executes arbitrary calldata as the delegator, however simple the transfer. Measured on Base mainnet, an ERC-20 transfer through MetaMask's delegation framework (the reference ERC-7710 implementation) cost 137,548 gas versus 93,559 gas through this ERC's reference implementation, about 32% less (reference transactions are listed in the discussion thread). The arbitrary calldata also shifts risk onto the facilitator, which must confirm the execution reduces to the transfer it expects; under this ERC the payment is a fixed typed struct verified with one eth_call simulation. Delegations must also be pre-created for a known delegate, which ad-hoc payments cannot satisfy: the payer learns the facilitator's identity only from the paywall response. ERC-7710 fits standing, scoped authority; this ERC settles one specific payment with one direct call on the account.

Native asset support

Token-level standards (ERC-3009, Permit2) cannot support native asset transfers, because there is no token contract for native ETH. This ERC uses the ERC-7528 native asset address convention to transfer both ERC-20 tokens and native assets through the same interface.

EIP-712 domain separator

The domain separator uses the smart account's own address as verifyingContract. The name SHOULD identify the smart account provider or implementation (e.g., "ExampleAccount") so wallet UIs display meaningful context. The version SHOULD reflect the implementation version. This allows different account providers to coexist while maintaining replay protection.

Authorization nonce design: random and isolated

This ERC uses the term authorizationNonce to explicitly distinguish it from the account's native transaction nonce (e.g., the ERC-4337 EntryPoint nonce). The authorization nonce space MUST be fully isolated from the account's native nonce. Isolation means normal account operations cannot invalidate outstanding payment authorizations, and unsettled authorizations do not prevent the user from using their account. Random nonces also make authorizations independent of one another, so they can settle in any order with no coordination.

from in the signed data but not in the function parameters

The from field is in the EIP-712 signed data but not a function parameter; the contract derives it as address(this). It remains in the signed data for ERC-3009 semantic compatibility and wallet UI clarity.

Backwards Compatibility

This ERC is purely additive and does not modify any existing standards.

  • Existing smart accounts are unaffected. Accounts that do not implement this interface continue to operate normally.
  • Existing token contracts do not need any changes. This ERC calls standard ERC-20 transfer on the token.
  • Existing facilitators and payment protocols can add support alongside their current methods (ERC-3009, Permit2, ERC-7710) without breaking existing flows.

Reference Implementation

AccountTransferAuthorization.sol

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

/**
 * @title AccountTransferAuthorization
 * @notice Reference implementation of this ERC for smart contract accounts.
 * @dev    This is a simplified implementation for illustration. Production
 *         implementations should integrate with their account's existing
 *         signature validation logic.
 */
abstract contract AccountTransferAuthorization is EIP712 {
    using SafeERC20 for IERC20;

    address public constant NATIVE_ASSET = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

    // keccak256("ExecuteTransferWithAuthorization(address token,address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 authorizationNonce)")
    bytes32 public constant EXECUTE_TRANSFER_WITH_AUTHORIZATION_TYPEHASH =
        0xe751bf1b144414a77b82ede1d2a433edc347fef283ab5e53e96f438392517b3c;

    // keccak256("ReceiveWithAuthorization(address token,address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 authorizationNonce)")
    bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH =
        0xd8a04c474fcb45b6fb4b17a80506c180af4818903f1ace6c1ff59053338529fd;

    // keccak256("CancelTransferAuthorization(bytes32 authorizationNonce)")
    bytes32 public constant CANCEL_TRANSFER_AUTHORIZATION_TYPEHASH =
        0xf30be15aedf9b01d0dac5525241af3753865a4968ffb9fb1a7ad6d2553d29f8e;

    mapping(bytes32 => bool) private _authorizationStates;

    event TransferAuthorizationUsed(
        address indexed token,
        address indexed from,
        address indexed to,
        uint256 value,
        bytes32 authorizationNonce
    );

    event TransferAuthorizationCanceled(
        bytes32 indexed authorizationNonce
    );

    error AuthorizationAlreadyUsed(bytes32 authorizationNonce);
    error AuthorizationNotYetValid(uint256 validAfter, uint256 currentTime);
    error AuthorizationExpired(uint256 validBefore, uint256 currentTime);
    error InvalidSignature();
    error NativeTransferFailed();
    error CallerNotPayee();
    error CallerNotSelf();

    constructor(string memory name, string memory version)
        EIP712(name, version)  // Smart account provider sets their own domain name and version
    {}

    function executeTransferWithAuthorization(
        address token,
        address to,
        uint256 value,
        uint256 validAfter,
        uint256 validBefore,
        bytes32 authorizationNonce,
        bytes calldata signature
    ) external {
        _authorizeAndTransfer(
            EXECUTE_TRANSFER_WITH_AUTHORIZATION_TYPEHASH,
            token, to, value, validAfter, validBefore, authorizationNonce, signature
        );
    }

    function receiveWithAuthorization(
        address token,
        address to,
        uint256 value,
        uint256 validAfter,
        uint256 validBefore,
        bytes32 authorizationNonce,
        bytes calldata signature
    ) external {
        // Only the payee may settle; prevents front-running outside the payee's flow
        if (msg.sender != to) revert CallerNotPayee();

        _authorizeAndTransfer(
            RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
            token, to, value, validAfter, validBefore, authorizationNonce, signature
        );
    }

    function transferAuthorizationState(
        bytes32 authorizationNonce
    ) external view returns (bool) {
        return _authorizationStates[authorizationNonce];
    }

    function cancelTransferAuthorization(
        bytes32 authorizationNonce,
        bytes calldata signature
    ) external {
        if (_authorizationStates[authorizationNonce]) revert AuthorizationAlreadyUsed(authorizationNonce);

        if (signature.length == 0) {
            // Signature-free cancellation: must be called by the account itself
            if (msg.sender != address(this)) revert CallerNotSelf();
        } else {
            // Signed cancellation: verify signature
            bytes32 structHash = keccak256(abi.encode(
                CANCEL_TRANSFER_AUTHORIZATION_TYPEHASH,
                authorizationNonce
            ));
            bytes32 digest = _hashTypedDataV4(structHash);
            _requireValidSignature(digest, signature);
        }

        _markAuthorizationUsed(authorizationNonce);
        emit TransferAuthorizationCanceled(authorizationNonce);
    }

    // ──────────────────────────────────────────────
    // Internal helpers
    // ──────────────────────────────────────────────

    // Shared by executeTransferWithAuthorization and receiveWithAuthorization.
    // The two differ only in the type hash (and receive's msg.sender == to check);
    // distinct type hashes keep the two authorizations non-interchangeable.
    function _authorizeAndTransfer(
        bytes32 typeHash,
        address token,
        address to,
        uint256 value,
        uint256 validAfter,
        uint256 validBefore,
        bytes32 authorizationNonce,
        bytes calldata signature
    ) private {
        _requireValidAuthorization(validAfter, validBefore, authorizationNonce);

        // `from` is address(this); included in the signed hash but not as a function parameter
        bytes32 structHash = keccak256(abi.encode(
            typeHash,
            token, address(this), to, value, validAfter, validBefore, authorizationNonce
        ));
        bytes32 digest = _hashTypedDataV4(structHash);

        _requireValidSignature(digest, signature);
        _markAuthorizationUsed(authorizationNonce);

        _executeTransfer(token, to, value);

        emit TransferAuthorizationUsed(token, address(this), to, value, authorizationNonce);
    }

    function _executeTransfer(
        address token,
        address to,
        uint256 value
    ) private {
        if (token == NATIVE_ASSET) {
            (bool success, ) = to.call{value: value}("");
            if (!success) revert NativeTransferFailed();
        } else {
            IERC20(token).safeTransfer(to, value);
        }
    }

    function _requireValidAuthorization(
        uint256 validAfter,
        uint256 validBefore,
        bytes32 authorizationNonce
    ) private view {
        if (_authorizationStates[authorizationNonce]) revert AuthorizationAlreadyUsed(authorizationNonce);
        if (block.timestamp <= validAfter) revert AuthorizationNotYetValid(validAfter, block.timestamp);
        if (block.timestamp >= validBefore) revert AuthorizationExpired(validBefore, block.timestamp);
    }

    function _markAuthorizationUsed(bytes32 authorizationNonce) private {
        _authorizationStates[authorizationNonce] = true;
    }

    function _requireValidSignature(
        bytes32 digest,
        bytes calldata signature
    ) internal view virtual;
}

Example: ECDSA owner account

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

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {AccountTransferAuthorization} from "./AccountTransferAuthorization.sol";

contract SimpleECDSAAccount is AccountTransferAuthorization {

    address public immutable owner;

    constructor(address _owner)
        AccountTransferAuthorization("SimpleECDSAAccount", "1")
    {
        owner = _owner;
    }

    function _requireValidSignature(
        bytes32 digest,
        bytes calldata signature
    ) internal view override {
        address recovered = ECDSA.recover(digest, signature);
        if (recovered != owner) revert InvalidSignature();
    }

    receive() external payable {}
}

Example: modular account with validator routing

Demonstrates how the bytes signature parameter enables routing to different signature validators. The first 20 bytes encode the validator address; the remainder is validator-specific signature data. Each validator implements ERC-1271's isValidSignature.

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

import {AccountTransferAuthorization} from "./AccountTransferAuthorization.sol";
import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";

contract ModularAccount is AccountTransferAuthorization {

    mapping(address => bool) public validators;

    constructor(address initialValidator)
        AccountTransferAuthorization("ModularAccount", "1")
    {
        validators[initialValidator] = true;
    }

    // Signature layout: [validator address (20 bytes)][validator-specific data]
    function _requireValidSignature(
        bytes32 digest,
        bytes calldata signature
    ) internal view override {
        if (signature.length < 20) revert InvalidSignature();

        address validator = address(bytes20(signature[:20]));
        if (!validators[validator]) revert InvalidSignature();

        // Delegate to validator's ERC-1271 isValidSignature
        bytes4 result = IERC1271(validator).isValidSignature(
            digest,
            signature[20:]
        );
        if (result != IERC1271.isValidSignature.selector) revert InvalidSignature();
    }

    receive() external payable {}
}

This pattern lets the same account support ECDSA, passkeys, multisig, or any future scheme by registering the appropriate validator module, all through the same executeTransferWithAuthorization interface.

Security Considerations

Front-running protection

executeTransferWithAuthorization is callable by anyone. An attacker monitoring the mempool could extract the signed authorization and submit it before the intended facilitator. However, unlike ERC-3009's token-level design, front-running at the account level does not change the payment outcome: the to address is cryptographically bound in the EIP-712 signature, so the funds still arrive at the correct recipient. The attacker merely pays gas on the facilitator's behalf. The facilitator can detect this by checking that the authorization nonce is already used and treating the payment as settled.

This protects the payment, but not a contract payee that must run logic atomically on receipt. A front-runner could settle via executeTransferWithAuthorization outside the payee's call and skip that logic. Such payees SHOULD use receiveWithAuthorization, which requires msg.sender == to. Receive authorizations are signed under a distinct ReceiveWithAuthorization type hash, so they cannot be replayed through executeTransferWithAuthorization. Where multiple payee entry points exist, leading nonce bytes MAY serve as an identifier to prevent cross-use (as in ERC-3009).

Replay protection

The EIP-712 domain separator includes chainId and verifyingContract (the smart account address). This ensures that:

  • Authorizations cannot be replayed across different chains.
  • Authorizations for one smart account cannot be used on another.
  • Each authorization is bound to a unique authorization nonce.

Smart account upgrade safety

If a smart account implementation uses a proxy pattern and is upgradeable, care must be taken to ensure that the authorization nonce storage mapping is preserved across upgrades. Implementations SHOULD use a dedicated storage slot or namespace.

Native asset reentrancy

When token is NATIVE_ASSET, the transfer is executed via to.call{value: value}(""), which forwards gas to the recipient. As noted in ERC-7528, using native assets instead of wrapped equivalents inherently exposes smart contract systems to reentrancy risks. However, the authorization nonce is marked as used before the transfer executes (checks-effects-interactions pattern), so reentrancy cannot replay the same authorization. Implementations MUST follow this ordering.

Phishing risks

Wallet UIs SHOULD clearly display the EIP-712 typed data to the user before signing, including the token address, recipient, amount, and time bounds.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth