EIP-4337 (Account Abstraction Using Alt Mempool)

Table of Contents

1. 背景介绍

Ethereum 中有两类帐户,一类是智能合约帐户,另一类是 EOA(Externally Owned Account)帐户。

普通用户一般使用的是 EOA 帐户类型。EOA 帐户提交 Tx 到链上时,需要使用私钥对 Tx 进行 ECDSA 签名;节点收到 Tx 后,会先校验 ECDSA 签名,签名通过后才会执行 Tx。我们可以 把对 Tx 的处理过程分为校验(Verification)和执行(Execution)两个阶段。 对于 EOA 帐户来说,Verification 阶段是固定不变的,就是校验 ECDSA 签名是否正确。

EIP 4337(Account Abstraction,简称 AA)中,把校验(Verification)和执行(Execution)两个阶段进行了解耦,让钱包开发者可以定制 Verfification 的逻辑,比如使用其它签名算法(如 BLS 签名)来校验合法性,或者使用多签机制来校验合法性。

在 EIP 4337 钱包提出来之前,就已经有一些智能合约钱包实现了类似机制(即定制校验逻辑), EIP 4337 的提出让智能合约钱包的开发更加标准化了。

注 1:了解 EOS 区块链的朋友应该知道 EOS 帐户是几个自定义字符,它对应有 Owner Key 和 Active Key,对帐户签名时只需要 Active Key 即可,Active Key 如果泄露了你可以使用 Owner Key 来换为一个 Active Key,我们可以认为 EOS 帐户具有简单的帐户抽象能力:帐户不变时可以更换其签名所需要的 Active Key。
注 2:有其它方式也可以实现 AA,比如 EIP 2938 所描述的:修改 Ethereum 底层协议,新定义 Tx 类型;但 EIP 4337 采取的是一种不修改 Ethereum 底层协议的方式,它引入了一个独立的 Mempool(称为 User Operation Mempool)来保存这类特殊的 Tx,再通过 Bundler 把 Tx 从 User Operation Mempool 中取出后打包上链。

2. EIP 4337

2.1. EIP 4337 核心概念

EIP 4337 由下面这些组件构成:

  1. User Operations:描述了用户的操作等信息。它由钱包 App 创建并提交给 Bundler。
  2. Bundlers:接收用户提交的 User Operation,把它放到 User Operation Mempool 中;监控 User Operation Mempool 中内容,把 User Operation 打包上链。具体来说, 用户通过 Bundler 的 RPC eth_sendUserOperation 可以提交 User Operation 到 User Operation Mempool 中;Bundler 调用 EntryPoint 合约的 handleOps 方法,可以把 User Operation 作为 handleOps 的参数提交上链。
  3. EntryPoint 合约:User Operation 的入口合约,实现了 handleOps 等方法。EntryPoint 不需要钱包开发者实现,目前社区已经部署上线了,其地址为 0x0576a174D229E3cFA37253523E645A78A0C91B57(EntryPoint 合约有升级,新的地址为 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789,参考节 4)。
  4. Account 合约:钱包开发者需要实现的合约,必须实现 validateUserOp (EntryPoint 合约的 handleOps 方法会调用它)来校验 User Operation 的合法性。
  5. Aggregator 合约:它是一个可选的合约。如果想实现签名聚合,则钱包开发者需要实现它。
  6. Paymaster 合约:它是一个可选的合约。如果想实现使用其它代币支付 Gas 等功能,则钱包开发者需要实现它。

2.2. Normal Tx 和 EIP 4337 Tx 的区别

普通 Tx 和 EIP 4337 Tx 执行流程的区别如表 1(摘自:EIP-4337 Detailed Workflow)所示。

eip4337_normal_tx_vs_4337_tx.png

Figure 1: Normal Tx VS. EIP 4337 Tx

我们可以看到,对于 EIP 4337 Tx 来说,用户的操作被封装在了 User Operation 对象中,调用 Bundler 的 RPC eth_sendUserOperation 可以提交 User Operation 到 User Operation Mempool 中;Bundler 通过调用 EntryPoint 合约的 handleOps 方法(这时会创建一个由 Bundler EOA 帐户签名的普通的 Tx),可以把 User Operation 作为 handleOps 的参数提交上链。

2.3. User Operation

User Operation 的结构如表 1 所示,通过 RPC eth_sendUserOperation 提交到 User Operation Mempool 时,需要把它编码为 ABI-encoded 对象。

Table 1: User Operation
Field Type Description
sender address 用户的 Account 合约地址
nonce uint256 防止重放攻击的参数,其含义由 Account 合约定义
initCode bytes 仅第一次创建 Account 合约时才需要。前 20 字节是 AccountFactory 的地址,从第 21 字节开始是调用 AccountFactory 时的 calldata
callData bytes 在运行 execution step 时,它是调用用户的 Account 合约时的 calldata
callGasLimit uint256 Gas limit for execution step
verificationGasLimit uint256 Gas limit for verification step
preVerificationGas uint256 Gas to compensate the bundler
maxFeePerGas uint256 Similar to EIP-1559 max_fee_per_gas
maxPriorityFeePerGas uint256 Similar to EIP-1559 max_priority_fee_per_gas
paymasterAndData bytes Paymaster Contract address and any extra data required for verification and execution
signature bytes signature 字段和 nonce 字段,在运行 verification step 时需要它们。signature 字段的含义由 Account 合约定义

用户的 Account 合约不需要提前部署就可以直接得到地址用于收款。这是由于这个合约是 AccountFactory 合约通过 create2 来创建,所以在创建之前就可以确定 Account 合约的地址了。

2.4. EntryPoint 合约

EntryPoint 合约在 EIP 4337 中是一个非常重要的合约,它已经通过审计并部署在各个 EVM 兼容链上了,合约地址为 0x0576a174D229E3cFA37253523E645A78A0C91B57(EntryPoint 合约有升级,新的地址为 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789,参考节 4)。

EntryPoint 的主要逻辑可分为 Verification Loop 和 Execution Loop 两个大循环。 在 Verification Loop 中会校验每个 User Operation 的合法性(这个阶段中如果发现 Account 合约还不存在,则会创建 Account 合约);在 Execution Loop 中执行每个 User Operation。

EntryPoint 的主要逻辑如图 2(摘自 ERC-4337 Overview, EntryPoint 图中的 Create2Factory 就是前面提到的 AccountFactory)所示。

eip4337_entrypoint_equence.svg

Figure 2: EIP4337 Entrypoint 核心逻辑

下面是 EntryPoint 合约 handleOps 的实现(摘自 EntryPoint.sol#L90):

    function handleOps(UserOperation[] calldata ops, address payable beneficiary) public {

        uint256 opslen = ops.length;
        UserOpInfo[] memory opInfos = new UserOpInfo[](opslen);

    unchecked {
        for (uint256 i = 0; i < opslen; i++) {                    // 这是 Verification Loop
            UserOpInfo memory opInfo = opInfos[i];
            (uint256 validationData, uint256 pmValidationData) = _validatePrepayment(i, ops[i], opInfo);
            _validateAccountAndPaymasterValidationData(i, validationData, pmValidationData, address(0));
        }

        uint256 collected = 0;

        for (uint256 i = 0; i < opslen; i++) {                    // 这是 Execution Loop
            collected += _executeUserOp(i, ops[i], opInfos[i]);
        }

        _compensate(beneficiary, collected);
    } //unchecked
    }

2.5. Account 合约

Account 合约就是用户的智能合约钱包。应该具备普通 EOA 钱包的所有能力,比如可以给别人转帐,可以调用其它的合约。

对于 Account 合约,钱包开发者至少需要实现下面两个函数:
1、校验 User Operation 签名的函数:

    function validateUserOp (UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) external returns (uint256 validationData);

自己实现 validateUserOp 时,需要实现的主要逻辑为:
a. 验证 User Operation 的 signature;
b. 把合约中的 nonce 值加 1(如果使用新版本的 EntryPoint,则不再需要这步了,参考节 4);
c. 支付至少 missingAccountFunds 的费用给 msg.sender 充当 Gas。

2、发起交易的函数,以实现给别人转帐或调用其它的合约。对于这个函数名,由开发者自己来决定,也可以实现多个。EntryPoint 调用 Account 合约执行 User Operation 时,是通过这种方式: address(account).call(callData) ,这是一种很灵活的方式。我们以这个 SimpleAccount 为例,它实现了发起交易的函数 execute

    function execute(address dest, uint256 value, bytes calldata func) external { // 也可是其它函数名,同步修改 User Operation 中 callData 的前 4 字节即可
        _requireFromEntryPointOrOwner();
        _call(dest, value, func);
    }

比如,想给某地址如 0xBf1dBEE4E7c1957E6491c3064F2A3AF2435a0D85 转帐 0.1 ETH 时,可以把 User Operation 的 callData 字段指定为:

0xb61d27f6000000000000000000000000bf1dbee4e7c1957e6491c3064f2a3af2435a0d85000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000

其中前 4 个字节 0xb61d27f6 是函数 execute(address,uint256,bytes) 的签名,后面的数据是函数参数的 ABI 编码:

 execute(address,uint256,bytes)
 ------------
 [000]: 000000000000000000000000bf1dbee4e7c1957e6491c3064f2a3af2435a0d85   # 目标地址。如果是合约交互,则改为合约地址即可
 [020]: 000000000000000000000000000000000000000000000000016345785d8a0000   # 往目标地址转账数量,0.1 ETH 的十六进制表达
 [040]: 0000000000000000000000000000000000000000000000000000000000000060   # 第 3 个参数的位置
 [060]: 0000000000000000000000000000000000000000000000000000000000000000   # 第 3 个参数的长度,这里为 0,普通转账不用提供 data。如果是合约交互,则改为合约的 callData 的长度,后面接合约 callData

2.5.1. AccountFactory 合约

Account 合约是 AccountFactory 合约通过 create2 创建,所以钱包开发者还需要实现 AccountFactory 合约。这里 SimpleAccountFactory 有 AccountFactory 的简单实现。

由于 Account 合约是使用 create2 创建的,所以用户在部署 Account 合约之前就可以得到 Account 合约的地址了,就可以进行收款了。只有当用户第一次使用 Account 合约时(比如往外转账),才有必要部署 Account 合约,具体方法是在 UserOperation 的 initCode 字段中指定 AccountFactory 合约地址和创建 Account 的参数,下一节将介绍具体细节。

2.5.2. 首次创建 Account 合约

首次提交 User Operation 时,必须设置 User Operation 的 initCode 字段。EntryPoint 发现 Account 合约(User Operation 的 sender 字段)在链上不存在时,会根据 initCode 字段来创建 Account 合约。具体方法是:

  1. 把 initCode 的前 20 字节解释为 AccountFactory 合约的地址;
  2. 把 initCode 从第 21 字节开始的数据作为调用 AccountFactory 时的 calldata。

EntryPoint 会调用下面函数来创建 Account 合约:

    /**
     * call the "initCode" factory to create and return the sender account address
     * @param initCode the initCode value from a UserOp. contains 20 bytes of factory address, followed by calldata
     * @return sender the returned address of the created account, or zero address on failure.
     */
    function createSender(bytes calldata initCode) external returns (address sender) {
        address factory = address(bytes20(initCode[0 : 20]));
        bytes memory initCallData = initCode[20 :];
        bool success;
        /* solhint-disable no-inline-assembly */
        assembly {
            success := call(gas(), factory, 0, add(initCallData, 0x20), mload(initCallData), 0, 32)
            sender := mload(0)
        }
        if (!success) {
            sender = address(0);
        }
    }

摘自:https://etherscan.io/address/0x0576a174D229E3cFA37253523E645A78A0C91B57#code

2.6. Paymaster

当 User Operation 的 paymasterAndData 字段不为空时,表示处理这个 User Operation 时使用 Paymaster,这样支付给 Bundler 的 Gas 不用 Account 合约出了。由 Paymaster 代付,关于 Bundler 从 Paymaster 获得 Gas 的具体流程可以参考节 2.7.2

Paymaster 合约需要实现下面方法:

    function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost)
        external returns (bytes memory context, uint256 validationData);

    function postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) external;

使用 Paymaster 时 User Operation 的提交流程如图 3 所示(摘自 EIP 4337,图中的 Wallet Contract 就是前面说的 Account Contract)。

eip4337_paymaster.png

Figure 3: Paymaster

2.6.1. Stake

为了防止恶意的 Paymaster 进行 DoS 攻击,Paymaster 需要先质押一些 ETH 到 EntryPoint 合约中,下面解释下这个问题。

EIP 4337 中有这样的描述:

If the paymaster’s validatePaymasterUserOp returns a “context”, then handleOps must call postOp on the paymaster after making the main execution call. It must guarantee the execution of postOp, by making the main execution inside an inner call context, and if the inner call context reverts attempting to call postOp again in an outer call context.

Maliciously crafted paymasters can DoS the system. To prevent this, we use a reputation system. paymaster must either limit its storage usage, or have a stake. see the reputation, throttling and banning section for details.

上面的大概意思是:EntryPoint 调用 IPaymaster(paymaster).validatePaymasterUserOp 方法后,如果它的第一个返回值 context 有数据,则 EntryPoint 在执行完 User Operation 中的逻辑后必须保证执行 IPaymaster(paymaster).postOp 。目前采用的做法是:把执行 User Operation 和调用 postOp 封装在函数 innerHandleOp 中,使用 try/catch 来执行 innerHandleOp,如果 innerHandleOp 抛出异常,则会在 catch 语句中再执行一次 postOp(不过这次指定的 mode 参数为 PostOpMode.postOpReverted,它和前一次调用 postOp 时指定的 mode 参数不一样,这样 postOp 中就能知道是什么场景被调用的了),下面是精简的代码(完整代码请参考 EntryPoint.sol#L58 ):

    function innerHandleOp(...) {
        IPaymaster.PostOpMode mode = IPaymaster.PostOpMode.opSucceeded;
        bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit);    // 执行 User Operation 的逻辑
        if (!success) {
                mode = IPaymaster.PostOpMode.opReverted;
            }
        }
        _handlePostOp(, mode, )           // 这里会第一次调用 postOp,mode 参数可能为 PostOpMode.opSucceeded 或者 PostOpMode.opReverted
    }

    function _executeUserOp(...) {        // 在 handleOps 的 Execution Loop 中执行
        try innerHandleOp(...) {
            // ......
        } catch {
            // ......
            _handlePostOp(, PostOpMode.postOpReverted, )  // 这里第二次调用 postOp,如果 revert 的话,会导致整个交易失败
        }
    }

我们注意到,如果 catch 处的 _handlePostOp 发生 revert,则会导致整个 bundle 的所有 User Operation 全部失败。 如果 Paymaster 在实现 postOp 时故意 revert,则将导致同一个交易中的其它 User Operation 也都失败,这是一种 DoS 攻击,为了阻止这种攻击,EIP 4337 中设计了 Stake 机制,这使得进行这种 DoS 攻击的代价大大增加。

可以通过 EntryPoint 的下面方法来管理 Stake 的资产:

    // add a paymaster stake (must be called by the paymaster)
    function addStake(uint32 _unstakeDelaySec) external payable

    // unlock the stake (must wait unstakeDelay before can withdraw)
    function unlockStake() external

    // withdraw the unlocked stake
    function withdrawStake(address payable withdrawAddress) external

2.7. Bundler(调用 EntryPoint 的 handleOps 方法把 UO 打包上链)

Bundler 是需要独立部署的程序,它的主要作用是打包多个 User Operations 上链,即创建 EntryPoint.handleOps() 交易。这里 stackup-bundler 有个 Bundler 的开源实现,如果不想自己部署,也可以直接使用 https://www.stackup.sh/ 提供的 Bundler 节点。

2.7.1. Bundler RPC

Bundler 需要实现下面 RPC:

eth_sendUserOperation
eth_estimateUserOperationGas
eth_getUserOperationByHash
eth_getUserOperationReceipt
eth_supportedEntryPoints

参考:https://eips.ethereum.org/EIPS/eip-4337#rpc-methods-eth-namespace

2.7.2. Bundler 得到 Gas 费的机制

Bundler 调用链上 EntryPoint 合约来处理 User Operation 时会消耗 Gas,这个 Gas 显然最终要补偿给 Bundler。

下面分两种情况讨论 Bundler 是如何得到 Gas 费:

  1. 不使用 Paymaster 时(即 User Operation 的 paymasterAndData 字段为空)。EntryPoint 合约的 handleOps 方法在它的 Verification Loop 中调用 Account 合约的 validateUserOp(userOp, userOpHash, aggregator, missingAccountFunds) 方法时,最后一个参数 missingAccountFunds 指定的就是 Account 合约需要转给 EntryPoint 合约的 Gas 费,在实现 validateUserOp 时,至少需要把这么多数量的 Gas 转给 EntryPoint 合约。如果转账的数量多于 missingAccountFunds ,则 EntryPoint 合约会给这个 Account 合约记帐(称为 Gas 帐户,它是 EntryPoint 合约的一个 mapping,可用于下次手续费的使用),通过 EntryPoint 合约中的方法 balanceOf/depositTo/withdrawTo 可以查看/增加/减少 Gas 账户。
  2. 使用 Paymaster 时(即 User Operation 的 paymasterAndData 字段不为空)。必须事先在 EntryPoint 合约中为 Paymaster 充值一些手续费(也是通过 EntryPoint 合约中的方法 balanceOf/depositTo/withdrawTo 可以查看/增加/减少这个 Gas 帐户)。因为,EntryPoint 合约的 handleOps 方法在它的 Verification Loop 中调用 IPaymaster(paymaster).validatePaymasterUserOp 前会检查 Gas 账户上 Paymaster 的手续费是否足够,如果足够就直接从 Gas 帐户中扣除;如果不够就报错。有一些方式来维持这个 Gas 账户上有足够的 ETH,如:A、每次在 EntryPoint 合约调用 IPaymaster(paymaster).validatePaymasterUserOp 时,paymaster 合约通过调用 EntryPoint 的 depositTo 来往 Gas 帐户充值;B、部署监控程序监控这个账户余额,低于某阈值了,就调用 EntryPoint 的 depositTo 来充值。

在节 2.4 中我们知道,EntryPoint 在执行 User Operation 时,有两个大循环:Verification Loop 和 Execution Loop。上面介绍的扣 Gas 费是在 Verification Loop 中,它只是预估的一个值,如果真实执行所消耗的 Gas 比这个少,则会在执行完 Execution Loop 时把多扣的 Gas 退还到 Gas 帐户中,参见:https://github.com/eth-infinitism/account-abstraction/blob/cedd0f66a156141f96677c7b89dd551351d8cfc1/contracts/core/EntryPoint.sol#L578

2.8. EIP 4337 实现

EIP 4337 实现源码可参考:https://github.com/eth-infinitism/account-abstraction ,其中不仅有 EntryPoint 的实例,还包含有 Account 合约的例子。关于 Account 合约的使用可参考:https://github.com/stackup-wallet/erc-4337-examples

2.8.1. EIP 4337 智能合约钱包的简单实现

下面是一个简单的 EIP 4337 智能合约钱包,它实现的校验逻辑很简单:一个 EOA 帐户(即合约中的 _owner)控制一个 EIP 4337 智能合约钱包(这里只是演示 EIP 4337 智能合约钱包的开发,它并没有什么实用性):

// SPDX-License-Identifier: MIT
// 下面代码中没有 nonce 的管理,可用于新版本 EntryPoint(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789),不能用于旧版 EntryPoint
pragma solidity ^0.8.19;

struct UserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    uint256 callGasLimit;
    uint256 verificationGasLimit;
    uint256 preVerificationGas;
    uint256 maxFeePerGas;
    uint256 maxPriorityFeePerGas;
    bytes paymasterAndData;
    bytes signature;
}

contract SimpleAccount {
    address private immutable _entryPoint;
    address _owner;

    uint256 constant internal SIG_VALIDATION_FAILED = 1;

    /* In order to receive native token, must implementation receive() or fallback() */
    receive() external payable {}

    constructor(address owner, address entryPoint) {
        _owner = owner;
        _entryPoint = entryPoint;
    }

    /**
     * Validate user's signature and send missingAccountFunds to entrypoint (msg.sender)
     */
    function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) external returns (uint256 validationData) {
        require(msg.sender == _entryPoint, "account: not EntryPoint");

        // Step 1: Verify signature
        bytes32 preHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", userOpHash)); // msg before sign
        bytes memory signature = userOp.signature;
        if (signature.length == 65) { // EIP-2098 short signature (64 bytes) is currently not supported
            // https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol
            bytes32 r;
            bytes32 s;
            uint8 v;
            /// @solidity memory-safe-assembly
            assembly {
                r := mload(add(signature, 0x20))
                s := mload(add(signature, 0x40))
                v := byte(0, mload(add(signature, 0x60)))
            }
            if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
                // The `ecrecover` EVM opcode allows for malleable (non-unique) signatures,
                // we rejects them by requiring the `s` value to be in the lower half order
                return SIG_VALIDATION_FAILED;
            }
            address signer = ecrecover(preHash, v, r, s);
            if (signer == address(0)) {
                return SIG_VALIDATION_FAILED;
            }
            if (signer != _owner) {
                return SIG_VALIDATION_FAILED;
            }
        } else {
            return SIG_VALIDATION_FAILED;
        }

        // Step 2: Send missingAccountFunds to the entrypoint (msg.sender)
        if (missingAccountFunds != 0) {
            (bool success,) = payable(msg.sender).call{value : missingAccountFunds, gas : type(uint256).max}("");
            (success);
            //ignore failure (its EntryPoint's job to verify, not account.)
        }

        return 0;
    }

    /**
     * execute a transaction (called directly from owner, or by entryPoint)
     */
    function execute(address dest, uint256 value, bytes calldata data) external {
        require(msg.sender == _entryPoint || msg.sender == _owner, "account: not Owner or EntryPoint");

        (bool success, bytes memory result) = dest.call{value : value}(data);
        if (!success) {
            assembly {
                revert(add(result, 32), mload(result))
            }
        }
    }
}

contract SimpleAccountFactory {
    event AccountCreated(address addr);

    constructor() {
    }

    function createAccount(bytes32 _salt, address _accountOwner, address _entryPoint) public returns (address) {
        // This syntax is a newer way to invoke create2 without assembly, you just need to pass salt
        // https://docs.soliditylang.org/en/latest/control-structures.html#salted-contract-creations-create2
        address addr = address(new SimpleAccount{salt: _salt}(_accountOwner, _entryPoint));
        emit AccountCreated(addr);
        return addr;
    }

    function getAddress(bytes32 _salt, address _accountOwner, address _entryPoint) public view returns (address) {
        address deployer = address(this);
        bytes32 bytecodeHash = keccak256(abi.encodePacked(
                type(SimpleAccount).creationCode,
                abi.encode(
                    address(_accountOwner),
                    address(_entryPoint)
                )));
        return computeAddress(_salt, bytecodeHash, deployer);
    }

    function computeAddress(bytes32 salt, bytes32 bytecodeHash, address deployer) internal pure returns (address addr) {
        /// @solidity memory-safe-assembly
        assembly {
            let ptr := mload(0x40) // Get free memory pointer

            // |                   | ↓ ptr ...  ↓ ptr + 0x0B (start) ...  ↓ ptr + 0x20 ...  ↓ ptr + 0x40 ...   |
            // |-------------------|---------------------------------------------------------------------------|
            // | bytecodeHash      |                                                        CCCCCCCCCCCCC...CC |
            // | salt              |                                      BBBBBBBBBBBBB...BB                   |
            // | deployer          | 000000...0000AAAAAAAAAAAAAAAAAAA...AA                                     |
            // | 0xFF              |            FF                                                             |
            // |-------------------|---------------------------------------------------------------------------|
            // | memory            | 000000...00FFAAAAAAAAAAAAAAAAAAA...AABBBBBBBBBBBBB...BBCCCCCCCCCCCCC...CC |
            // | keccak(start, 85) |            ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ |

            mstore(add(ptr, 0x40), bytecodeHash)
            mstore(add(ptr, 0x20), salt)
            mstore(ptr, deployer) // Right-aligned with 12 preceding garbage bytes
            let start := add(ptr, 0x0b) // The hashed data starts at the final garbage byte which we will set to 0xff
            mstore8(start, 0xff)
            addr := keccak256(start, 85)
        }
    }
}

测试步骤:

  1. 部署 SimpleAccountFactory 合约;
  2. 调用 SimpleAccountFactory 合约的 createAccount 方法来部署 SimpleAccount 合约;
  3. 往部署好的 SimpleAccount 合约中转入少量的 ETH,
  4. 构造 User Operation(由于 SimpleAccount 提前部署了,所以 initCode 可以为空),通过 Bundler 的 RPC eth_sendUserOperation 提交给 Bundler。

3. EIP 4337 问题

3.1. EIP 4337 钱包无法使用禁止了合约帐户的 DApp

有一些 DApp 只允许 EOA 帐户调用 DApp 合约,禁止了合约帐户调用 DApp 合约。具体方法是在 DApp 合约函数中使用 require(tx.origin == msg.sender); 来确保调用 DApp 合约函数的一定是个 EOA 地址。因为如果交易是 EOA 地址发起的,则 tx.originmsg.sender 是相同的,反之如果交易是智能合约发起的,则 tx.origin (打包 tx 的 EOA 地址)和 msg.sender (调用 DApp 合约的合约地址)不相同。

由于 EIP 4337 钱包本质上也是一个智能合约,所以 EIP 4337 钱包将无法使用这种代码中有 require(tx.origin == msg.sender); 的 DApp。

3.2. EIP 4337 钱包无法使用不支持 EIP 1271 的 DApp

如果 DApp 合约存在使用 EIP 191/EIP 712 标准来验证签名数据的逻辑,这样的 DApp 只能被 EOA 帐户使用,不能被合约帐户使用。这是因为合约帐户背后并不存在像 EOA 帐户那样的私钥,从而合约帐户自己是无法生成 EIP 191/EIP 712 所需要的签名数据。

为了解决上面问题,EIP 1271 被提出,它的方案是:1. 智能合约钱包需要实现方法 isValidSignature 。2. 验证方(即 DApp 合约)如果发现和它交互的帐户是 EOA 地址,则还是按照 EIP 191/EIP 712 标准,通过调用 ecrecover 来验证签名数据;DApp 若发现和它交互的是一个合约地址,则不调用 ecrecover 验证签名,而是通过调用智能合约钱包的方法 isValidSignature 来验证签名。

显然, EIP 1271 需要 DApp 合约做适配,如果 DApp 合约使用了 EIP 191/EIP 712 但却不支持 EIP 1271,则这个 DApp 只能被 EOA 使用,无法被智能合约(EIP 4337 钱包)使用。

注:EIP 1271 有一个限制:由于使用 create2 部署的合约可以在部署之前就确定合约地址,这样没有部署的智能合约也能正常收款。“还没有部署的智能合约”显然还不能被 DApp 合约按照 EIP 1271 的方式来验证签名,关于这个问题在 account-abstraction #188 中有记录,它的解决方案 EIP 6492,具体做法是签名时如果智能合约没有部署则在产生的签名数据中还附带其它信息(部署钱包所需要的信息),验证方发现这个特别的签名数据后,先去部署合约,部署完后再进行 EIP 1271 的验证。

4. 新的 EntryPoint 合约

EntryPoint 上线(0x0576a174D229E3cFA37253523E645A78A0C91B57,这个版本是 0.5.0)后,发布了新版本(0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789,这个版本是 0.6.0),它主要有下面变化有:

  1. Align on-chain and off-chain UserOperation hash - PR#245
  2. Nonce managed by EntryPoint - PR#247
  3. Prevent recursion of handleOps - PR#257

其中第 2 点(nonce 值以前由 Account 合约管理,现在改为了由 EntryPoint 管理)对钱包开发者有影响,钱包开发者不再需要在 Account 合约中检查和增加 nonce 值了。

参考:EntryPoint 0.6.0 Released

注:EntryPoint 还在持续更新中,如 0x0000000071727De22E5E9d8BAf0edAc6f37da032,这个版本是 0.7.0,本文不再介绍这些新变化了,可参考:EntryPoint 0.7.0 Released

5. 参考

Author: cig01

Created: <2023-03-25 Sat>

Last updated: <2024-02-24 Sat>

Creator: Emacs 27.1 (Org mode 9.4)