Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-6981: 保留所有权账户

一个用于生成未来部署的智能合约账户的注册表,这些账户由外部服务的用户拥有

Authors Paul Sullivan (@sullivph) <paul.sullivan@manifold.xyz>, Wilkins Chung (@wwchung) <wilkins@manifold.xyz>, Kartik Patel (@Slokh) <kartik@manifold.xyz>
Created 2023-04-25
Discussion Link https://ethereum-magicians.org/t/erc-6981-reserved-ownership-accounts/14118
Requires EIP-1167, EIP-1271, EIP-6492

摘要

以下内容指定了一个系统,供服务将其用户链接到可声明的以太坊地址。服务可以向其用户提供签名消息和唯一 salt,这些信息可用于通过使用 create2 操作码的注册表合约将智能合约钱包部署到确定性地址。

动机

Web 服务通常允许其用户通过托管钱包持有链上资产。这些钱包通常是 EOA、已部署的智能合约钱包或 omnibus 合约,私钥或资产所有权信息存储在传统数据库中。本提案概述了一种解决方案,该方案避免了与历史方法相关的安全问题,并且消除了服务控制用户资产的需求和影响

选择利用以下规范的外部服务上的用户可以获得一个以太坊地址来接收资产,而无需进行任何链上交易。这些用户可以选择在未来的某个时间点获得对所述地址的控制权。因此,链上资产可以预先发送给用户并由其拥有,从而无需用户与底层区块链进行交互即可形成链上身份。

规范

本文档中的关键词 “MUST”,“MUST NOT”,“REQUIRED”,“SHALL”,“SHALL NOT”,“SHOULD”,“SHOULD NOT”,“RECOMMENDED”,“NOT RECOMMENDED”,“MAY” 和 “OPTIONAL” 按照 RFC 2119 和 RFC 8174 中的描述进行解释。

概述

用于创建保留所有权帐户的系统包括:

  1. 帐户注册表,该注册表基于服务用户的识别 salt 提供确定性地址,并实现签名验证功能,使服务的最终用户能够声明帐户实例。
  2. 最终用户通过帐户注册表创建的帐户实例,这些实例允许访问在帐户实例部署之前在确定性地址接收到的资产。

希望向其用户提供保留所有权帐户的外部服务 MUST 维护用户识别凭据和 salt 之间的关系。外部服务 SHALL 引用帐户注册表实例以检索给定 salt 的确定性帐户地址。给定服务的用户 MUST 能够通过验证其通过外部服务获得的识别凭据来创建帐户实例,这 SHOULD 为用户提供针对其 salt 的签名消息。签名 SHOULD 由外部服务使用帐户注册表实例已知的签名地址生成。用户 SHALL 将此消息和签名传递给服务的帐户注册表实例,并调用 claimAccount 以在确定性地址部署和声明帐户实例。

帐户注册表

帐户注册表 MUST 实现以下接口:

interface IAccountRegistry {
    /**
     * @dev 注册表实例在成功创建帐户后发出 AccountCreated 事件
     */
    event AccountCreated(address account, address accountImplementation, uint256 salt);

    /**
     * @dev 注册表实例在所有者成功声明帐户后发出 AccountClaimed 事件
     */
    event AccountClaimed(address account, address owner);

    /**
     * @dev 创建一个智能合约帐户。
     *
     * 如果帐户已创建,则返回帐户地址而不调用 create2。
     *
     * @param salt       - 用户希望为其部署 Account Instance 的识别 salt
     *
     * 发出 AccountCreated 事件
     * @return 创建 Account Instance 的地址
     */
    function createAccount(uint256 salt) external returns (address);

    /**
     * @dev 允许所有者声明由此注册表创建的智能合约帐户。
     *
     * 如果尚未创建该帐户,则将首先使用 `createAccount` 创建该帐户
     *
     * @param owner      - 新 Account Instance 的初始所有者
     * @param salt       - 用户希望为其部署 Account Instance 的识别 salt
     * @param expiration - 如果 expiration > 0,则表示签名的到期时间。否则
     *                     签名不会过期。
     * @param message    - 验证所有者、salt、到期时间的 keccak256 消息
     * @param signature  - 验证所有者、salt、到期时间的签名
     *
     * 发出 AccountClaimed 事件
     * @return 声明的 Account Instance 的地址
     */
    function claimAccount(
        address owner,
        uint256 salt,
        uint256 expiration,
        bytes32 message,
        bytes calldata signature
    ) external returns (address);

    /**
     * @dev 返回给定识别 salt 的智能合约帐户的计算地址
     *
     * @return 该帐户的计算地址
     */
    function account(uint256 salt) external view returns (address);

    /**
     * @dev 未声明帐户的回退签名验证
     */
    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4);
}

createAccount

createAccount 用于为给定的 salt 部署 Account Instance。

  • 此函数 MUST 部署新的 Account Instance 作为指向帐户实现的 ERC-1167 代理。
  • 此函数 SHOULD 将 Account Instance 的初始所有者设置为 Account Registry Instance。
  • 帐户实现地址 MUST 是不可变的,因为它用于计算 Account Instance 的确定性地址。
  • 成功部署 Account Instance 后,注册表 SHOULD 发出 AccountCreated 事件。

claimAccount

claimAccount 用于声明给定 salt 的 Account Instance 的所有权。

  • 如果给定 salt 的帐户实例尚不存在,则此函数 MUST 创建一个新的帐户实例。
  • 此函数 SHOULD 验证 msg.sender 是否有权声明给定识别 salt 和初始所有者的 Account Instance 的所有权。验证 SHOULD 通过使用 ECDSA(对于 EOA 签名者)或 ERC-1271(对于智能合约签名者)验证所有者、salt 和到期时间的消息和签名来完成。
  • 此函数 SHOULD 验证 block.timestamp < expiration 或 expiration == 0。
  • 在成功验证对 claimAccount 的调用的签名后,注册表 MUST 完全放弃对 Account Instance 的控制,并通过在 Account Instance 上调用 setOwner 将所有权分配给初始所有者。
  • 成功声明 Account Instance 后,注册表 SHOULD 发出 AccountClaimed 事件。

isValidSignature

isValidSignature 是未声明帐户使用的回退签名验证函数。有效的签名 SHALL 由注册表签名者通过签署原始消息哈希和 Account Instance 地址的组合哈希来生成(例如,bytes32 compositeHash = keccak256(abi.encodePacked(originalHash, accountAddress)))。该函数 MUST 重构组合哈希,其中 originalHash 是传递给该函数的哈希,而 accountAddressmsg.sender(未声明的 Account Instance)。该函数 MUST 验证根据组合哈希和注册表签名者的签名。

帐户实例

帐户实例 MUST 实现以下接口:

interface IAccount is IERC1271 {
    /**
     * @dev 设置 Account Instance 的所有者。
     *
     * 只能由实例的当前所有者调用,或者由注册表(如果 Account
     * Instance 尚未声明)。
     *
     * @param owner      - Account Instance 的新所有者
     */
    function setOwner(address owner) external;
}
  • 所有帐户实例 MUST 使用帐户注册表实例创建。
  • 帐户实例 SHOULD 提供对先前发送到在部署帐户实例的地址的资产的访问。
  • setOwner SHOULD 更新所有者,并且 SHOULD 可由 Account Instance 的当前所有者调用。
  • 如果部署了某个 Account Instance,但未声明,则 Account Instance 的所有者 MUST 初始化为 Account Registry Instance。
  • 帐户实例 SHALL 通过检查所有者是否为帐户注册表实例来确定是否已声明。

帐户实例签名

帐户实例 MUST 通过实现 isValidSignature 函数来支持 ERC-1271。当某个 Account Instance 的所有者想要签署消息时(例如,登录到 dApp),签名 MUST 以以下方式之一生成,具体取决于 Account Instance 的状态:

  1. 如果 Account Instance 已部署并已声明,则所有者应生成签名,并且 isValidSignature SHOULD 验证消息哈希和签名对于 Account Instance 的当前所有者是否有效。
  2. 如果 Account Instance 已部署但未声明,则注册表签名者应使用原始消息和 Account Instance 地址的组合哈希生成签名(如 上文 所述),并且 isValidSignature SHOULD 将消息哈希和签名转发到 Account Registry Instance 的 isValidSignature 函数。
  3. 如果未部署 Account Instance,则注册表签名者应按照情况 2 中的方式在复合哈希上生成签名,并根据 ERC-6492 包装签名(例如 concat(abi.encode((registryAddress, createAccountCalldata, compositeHashSignature), (address, bytes, bytes)), magicBytes))。

应根据 ERC-6492 完成 Account Instance 的签名验证。

理由

服务拥有的注册表实例

虽然为保留所有权帐户实现和部署通用注册表似乎对用户更友好,但我们认为外部服务提供商可以选择拥有和控制自己的帐户注册表非常重要。 这提供了实现自己的权限控制和帐户部署授权框架的灵活性。

我们正在提供一个参考注册表工厂,该工厂可以为外部服务部署帐户注册表,该工厂带有:

  • 不可变的 Account Instance 实现
  • 通过 ECDSA 为 EOA 签名者验证 claimAccount 方法,或通过 ERC-1271 验证来验证智能合约签名者
  • 帐户注册表部署者可以更改用于 claimAccount 验证的签名地址的能力

帐户注册表和帐户实现耦合

由于帐户实例部署为 ERC-1167 代理,因此帐户实现地址会影响从给定帐户注册表部署的帐户的地址。 要求注册表实例链接到单个、不可变的帐户实现可确保用户在一个给定的帐户注册表实例上的 salt 与链接地址之间保持一致性。

这也使服务可以通过部署其注册表并引用受信任的帐户实现地址来获得用户的信任。

此外,帐户实现可以设计为可升级的,因此用户不一定受限于用于创建其帐户的帐户注册表实例指定的实现。

分离 createAccountclaimAccount 操作

创建和声明 Account Instance 的操作是有意分离的。 这允许服务在其 Account Instance 部署之前为用户提供有效的 ERC-6492 签名。

参考实现

以下是帐户注册表工厂的示例,外部服务提供商可以使用该工厂来部署自己的帐户注册表实例。

帐户注册表工厂

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

/// @author: manifold.xyz

import {Create2} from "openzeppelin/utils/Create2.sol";

import {Address} from "../../lib/Address.sol";
import {ERC1167ProxyBytecode} from "../../lib/ERC1167ProxyBytecode.sol";
import {IAccountRegistryFactory} from "./IAccountRegistryFactory.sol";

contract AccountRegistryFactory is IAccountRegistryFactory {
    using Address for address;

    error InitializationFailed();

    address private immutable registryImplementation = 0x076B08EDE2B28fab0c1886F029cD6d02C8fF0E94;

    function createRegistry(
        uint96 index,
        address accountImplementation,
        bytes calldata accountInitData
    ) external returns (address) {
        bytes32 salt = _getSalt(msg.sender, index);
        bytes memory code = ERC1167ProxyBytecode.createCode(registryImplementation);
        address _registry = Create2.computeAddress(salt, keccak256(code));

        if (_registry.isDeployed()) return _registry;

        _registry = Create2.deploy(0, salt, code);

        (bool success, ) = _registry.call(
            abi.encodeWithSignature(
                "initialize(address,address,bytes)",
                msg.sender,
                accountImplementation,
                accountInitData
            )
        );
        if (!success) revert InitializationFailed();

        emit AccountRegistryCreated(_registry, accountImplementation, index);

        return _registry;
    }

    function registry(address deployer, uint96 index) external view override returns (address) {
        bytes32 salt = _getSalt(deployer, index);
        bytes memory code = ERC1167ProxyBytecode.createCode(registryImplementation);
        return Create2.computeAddress(salt, keccak256(code));
    }

    function _getSalt(address deployer, uint96 index) private pure returns (bytes32) {
        return bytes32(abi.encodePacked(deployer, index));
    }
}

帐户注册表

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

/// @author: manifold.xyz

import {Create2} from "openzeppelin/utils/Create2.sol";
import {ECDSA} from "openzeppelin/utils/cryptography/ECDSA.sol";
import {Ownable} from "openzeppelin/access/Ownable.sol";
import {Initializable} from "openzeppelin/proxy/utils/Initializable.sol";
import {IERC1271} from "openzeppelin/interfaces/IERC1271.sol";
import {SignatureChecker} from "openzeppelin/utils/cryptography/SignatureChecker.sol";

import {Address} from "../../lib/Address.sol";
import {IAccountRegistry} from "../../interfaces/IAccountRegistry.sol";
import {ERC1167ProxyBytecode} from "../../lib/ERC1167ProxyBytecode.sol";

contract AccountRegistryImplementation is Ownable, Initializable, IAccountRegistry {
    using Address for address;
    using ECDSA for bytes32;

    struct Signer {
        address account;
        bool isContract;
    }

    error InitializationFailed();
    error ClaimFailed();
    error Unauthorized();

    address public accountImplementation;
    bytes public accountInitData;
    Signer public signer;

    constructor() {
        _disableInitializers();
    }

    function initialize(
        address owner,
        address accountImplementation_,
        bytes calldata accountInitData_
    ) external initializer {
        _transferOwnership(owner);
        accountImplementation = accountImplementation_;
        accountInitData = accountInitData_;
    }

    /**
     * @dev See {IAccountRegistry-createAccount}
     */
    function createAccount(uint256 salt) external override returns (address) {
        bytes memory code = ERC1167ProxyBytecode.createCode(accountImplementation);
        address _account = Create2.computeAddress(bytes32(salt), keccak256(code));

        if (_account.isDeployed()) return _account;

        _account = Create2.deploy(0, bytes32(salt), code);

        (bool success, ) = _account.call(accountInitData);
        if (!success) revert InitializationFailed();

        emit AccountCreated(_account, accountImplementation, salt);

        return _account;
    }

    /**
     * @dev See {IAccountRegistry-claimAccount}
     */
    function claimAccount(
        address owner,
        uint256 salt,
        uint256 expiration,
        bytes32 message,
        bytes calldata signature
    ) external override returns (address) {
        _verify(owner, salt, expiration, message, signature);
        address _account = this.createAccount(salt);

        (bool success, ) = _account.call(
            abi.encodeWithSignature("transferOwnership(address)", owner)
        );
        if (!success) revert ClaimFailed();

        emit AccountClaimed(_account, owner);
        return _account;
    }

    /**
     * @dev See {IAccountRegistry-account}
     */
    function account(uint256 salt) external view override returns (address) {
        bytes memory code = ERC1167ProxyBytecode.createCode(accountImplementation);
        return Create2.computeAddress(bytes32(salt), keccak256(code));
    }

    /**
     * @dev See {IAccountRegistry-isValidSignature}
     */
    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4) {
        bytes32 expectedHash = keccak256(abi.encodePacked(hash, msg.sender));
        bool isValid = SignatureChecker.isValidSignatureNow(
            signer.account,
            expectedHash,
            signature
        );
        if (isValid) {
            return IERC1271.isValidSignature.selector;
        }

        return "";
    }

    function updateSigner(address newSigner) external onlyOwner {
        uint32 signerSize;
        assembly {
            signerSize := extcodesize(newSigner)
        }
        signer.account = newSigner;
        signer.isContract = signer.isContract = signerSize > 0;
    }

    function _verify(
        address owner,
        uint256 salt,
        uint256 expiration,
        bytes32 message,
        bytes calldata signature
    ) internal view {
        address signatureAccount;

        if (signer.isContract) {
            if (!SignatureChecker.isValidSignatureNow(signer.account, message, signature))
                revert Unauthorized();
        } else {
            signatureAccount = message.recover(signature);
        }

        bytes32 expectedMessage = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n84", owner, salt, expiration)
        );

        if (
            message != expectedMessage ||
            (!signer.isContract && signatureAccount != signer.account) ||
            (expiration != 0 && expiration < block.timestamp)
        ) revert Unauthorized();
    }
}

示例帐户实现

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

/// @author: manifold.xyz

import {IERC1271} from "openzeppelin/interfaces/IERC1271.sol";
import {SignatureChecker} from "openzeppelin/utils/cryptography/SignatureChecker.sol";
import {IERC165} from "openzeppelin/utils/introspection/IERC165.sol";
import {ERC165Checker} from "openzeppelin/utils/introspection/ERC165Checker.sol";
import {IERC721} from "openzeppelin/token/ERC721/IERC721.sol";
import {IERC721Receiver} from "openzeppelin/token/ERC721/IERC721Receiver.sol";
import {IERC1155Receiver} from "openzeppelin/token/ERC1155/IERC1155Receiver.sol";
import {Initializable} from "openzeppelin/proxy/utils/Initializable.sol";
import {Ownable} from "openzeppelin/access/Ownable.sol";
import {IERC1967Account} from "./IERC1967Account.sol";

import {IAccount} from "../../interfaces/IAccount.sol";

/**
 * @title ERC1967AccountImplementation
 * @notice 轻量级的可升级智能合约钱包实现
 */
contract ERC1967AccountImplementation is
    IAccount,
    IERC165,
    IERC721Receiver,
    IERC1155Receiver,
    IERC1967Account,
    Initializable,
    Ownable
{
    address public registry;

    constructor() {
        _disableInitializers();
    }

    function initialize() external initializer {
        registry = msg.sender;
        _transferOwnership(registry);
    }

    function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
        return (interfaceId == type(IAccount).interfaceId ||
            interfaceId == type(IERC1967Account).interfaceId ||
            interfaceId == type(IERC1155Receiver).interfaceId ||
            interfaceId == type(IERC721Receiver).interfaceId ||
            interfaceId == type(IERC165).interfaceId);
    }

    function onERC721Received(
        address,
        address,
        uint256,
        bytes memory
    ) public pure returns (bytes4) {
        return this.onERC721Received.selector;
    }

    function onERC1155Received(
        address,
        address,
        uint256,
        uint256,
        bytes memory
    ) public pure returns (bytes4) {
        return this.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address,
        address,
        uint256[] memory,
        uint256[] memory,
        bytes memory
    ) public pure returns (bytes4) {
        return this.onERC1155BatchReceived.selector;
    }

    /**
     * @dev {See IERC1967Account-executeCall}
     */
    function executeCall(
        address _target,
        uint256 _value,
        bytes calldata _data
    ) external payable override onlyOwner returns (bytes memory _result) {
        bool success;
        // solhint-disable-next-line avoid-low-level-calls
        (success, _result) = _target.call{value: _value}(_data);
        require(success, string(_result));
        emit TransactionExecuted(_target, _value, _data);
        return _result;
    }

    /**
     * @dev {See IAccount-setOwner}
     */
    function setOwner(address _owner) external override onlyOwner {
        _transferOwnership(_owner);
    }

    receive() external payable {}

    function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4) {
        if (owner() == registry) {
            return IERC1271(registry).isValidSignature(hash, signature);
        }

        bool isValid = SignatureChecker.isValidSignatureNow(owner(), hash, signature);
        if (isValid) {
            return IERC1271.isValidSignature.selector;
        }

        return "";
    }
}

安全注意事项

抢跑交易

通过调用 createAccount 通过帐户注册表实例部署保留所有权帐户可能会被恶意行为者抢先运行。 但是,如果恶意行为者试图更改 calldata 中的 owner 参数,则帐户注册表实例会发现签名无效,并还原该交易。 因此,任何成功的抢先运行交易都会将相同的 Account Instance 部署到原始交易,并且原始所有者仍然可以控制该地址。

版权

版权及相关权利通过 CC0 放弃。

Citation

Please cite this document as:

Paul Sullivan (@sullivph) <paul.sullivan@manifold.xyz>, Wilkins Chung (@wwchung) <wilkins@manifold.xyz>, Kartik Patel (@Slokh) <kartik@manifold.xyz>, "ERC-6981: 保留所有权账户 [DRAFT]," Ethereum Improvement Proposals, no. 6981, April 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6981.