ERC-6551 详解

最近 ERC-6551 的热度很高,我们就来聊聊 ERC-6551 到底是什么。这篇文章我想从它的原理,代码实现,以及应用案例来讲解 6551。相信看完这篇文章后大家应该会对 6551 有一个比较全面的认识。

什么是 ERC-6551

简单来说,6551 是一个为 NFT 创建钱包的标准。这是什么意思呢?

我们考虑一个场景,假设在游戏中,我的地址 A 拥有一个角色 Bob,这个角色它本身是一个 ERC721 的 NFT,同时它的身上也有许多道具,例如帽子,鞋子,武器等,也可能拥有一些资产例如金元宝等。这些资产本身也是 ERC20,ERC721 等类型的 token。这些道具和资产在游戏逻辑中是属于我的角色 Bob 的,但是在实际的底层合约实现中,其实它们都是属于我的地址 A 的。如果我想将我的角色 Bob 出售给别人,我就需要分别将 Bob 还有它所拥有的所有资产都一一转账给买家。这在逻辑和操作上都不是很合理。

6551 标准的目的就是为角色 Bob 创建一个钱包,使得它所拥有的道具都是属于角色 Bob 的,这样看起来就合理多了。我们可以看看官方 EIP 里给到的示例图:

ERC-6551
ERC-6551

用户账户中拥有两个 NFT,分别是 A#123 和 B#456,其中 A#123 拥有两个账户( A 和 B),B#456 拥有一个账户(C)。

我们再来看看图中右边这一块是什么意思。6551 协议中提供了一个 Registry 合约,它是用来创建 NFT 钱包的合约,通过调用它的 createAccount 函数就可以对该 NFT 创建一个合约钱包。由于合约钱包的写法可以各式各样,因此该函数需要我们提供一个合约钱包的 Implementation。也就是说我们可以自定义钱包的合约,例如可以是 AA 钱包,可以是 Safe 钱包。图中还有 proxies 关键字,这表示 Registry 在创建钱包的时候是创建了 Implementation 的代理,而不是原模原样将其复制了一份,这样做的目的是为了节省 Gas。这块应用到了 EIP-1167 的知识,对其不熟悉的朋友可以看看我之前的文章

6551 EIP 中把为 NFT 创建的钱包称为 token bound accounts。它有一个很重要的特性是可以向前兼容 NFT,对于之前链上已经部署的标准 NFT 合约,都可以兼容 6551。

我们平时使用的钱包,不论是 EOA 还是合约钱包,都是钱包本身拥有资产。但在 NFT 账户中,NFT 本身是不拥有资产的,而是它拥有一个合约钱包,该钱包是拥有资产的主体。也就是说 NFT 本身更类似于的角色。用图说明应该更清晰一点:

代码实现

6551 标准建议 Registry 实现 IERC6551Registry 接口:

interface IERC6551Registry {
    /// @dev The registry SHALL emit the AccountCreated event upon successful account creation
    event AccountCreated(
        address account,
        address implementation,
        uint256 chainId,
        address tokenContract,
        uint256 tokenId,
        uint256 salt
    );

    /// @dev Creates a token bound account for an ERC-721 token.
    ///
    /// If account has already been created, returns the account address without calling create2.
    ///
    /// If initData is not empty and account has not yet been created, calls account with
    /// provided initData after creation.
    ///
    /// Emits AccountCreated event.
    ///
    /// @return the address of the account
    function createAccount(
        address implementation,
        uint256 chainId,
        address tokenContract,
        uint256 tokenId,
        uint256 salt,
        bytes calldata initData
    ) external returns (address);

    /// @dev Returns the computed address of a token bound account
    ///
    /// @return The computed address of the account
    function account(
        address implementation,
        uint256 chainId,
        address tokenContract,
        uint256 tokenId,
        uint256 salt
    ) external view returns (address);
}


createAccount 函数用来为 NFT 创建合约钱包,account 函数用来计算合约钱包地址。它们都利用了 create2 机制,因此可以返回一个确定的地址。对 create2 不了解的朋友可以看我之前的文章

6551 官方给出了一个已经部署的 Registry 实现可供参考。

其中的 getCreationCode 函数值得留意一下:

function getCreationCode(
    address implementation_,
    uint256 chainId_,
    address tokenContract_,
    uint256 tokenId_,
    uint256 salt_
) internal pure returns (bytes memory) {
    // 将 salt, chainId, tokenContract, tokenId 拼接在 bytecode 后面
    return
        abi.encodePacked(
            hex"3d60ad80600a3d3981f3363d3d373d3d3d363d73",
            implementation_,
            hex"5af43d82803e903d91602b57fd5bf3",
            abi.encode(salt_, chainId_, tokenContract_, tokenId_)
        );
}


它是用来组装 proxy 字节码的函数,可以看到在最后将 salt_chainId_tokenContract_tokenId_ 等数据拼接在了 EIP-1167 的代理字节码后面。这里的目的是为了在创建的合约钱包中可以直接通过字节码读取到这些数据,我们之前学习过的 SudoSwap 代码中也使用了类似的写法,参考这里

对于创建的 NFT 钱包合约(即 Implementation 合约),6551 也作出了一些要求:

  • 应该通过 Registry 创建

  • 必须实现 ERC-165 接口

  • 必须实现 ERC-1271 接口

  • 必须实现下面的 IERC6551Account 接口:

/// @dev the ERC-165 identifier for this interface is `0x400a0398`
interface IERC6551Account {
    /// @dev Token bound accounts MUST implement a `receive` function.
    ///
    /// Token bound accounts MAY perform arbitrary logic to restrict conditions
    /// under which Ether can be received.
    receive() external payable;

    /// @dev Executes `call` on address `to`, with value `value` and calldata
    /// `data`.
    ///
    /// MUST revert and bubble up errors if call fails.
    ///
    /// By default, token bound accounts MUST allow the owner of the ERC-721 token
    /// which owns the account to execute arbitrary calls using `executeCall`.
    ///
    /// Token bound accounts MAY implement additional authorization mechanisms
    /// which limit the ability of the ERC-721 token holder to execute calls.
    ///
    /// Token bound accounts MAY implement additional execution functions which
    /// grant execution permissions to other non-owner accounts.
    ///
    /// @return The result of the call
    function executeCall(
        address to,
        uint256 value,
        bytes calldata data
    ) external payable returns (bytes memory);

    /// @dev Returns identifier of the ERC-721 token which owns the
    /// account
    ///
    /// The return value of this function MUST be constant - it MUST NOT change
    /// over time.
    ///
    /// @return chainId The EIP-155 ID of the chain the ERC-721 token exists on
    /// @return tokenContract The contract address of the ERC-721 token
    /// @return tokenId The ID of the ERC-721 token
    function token()
        external
        view
        returns (
            uint256 chainId,
            address tokenContract,
            uint256 tokenId
        );

    /// @dev Returns the owner of the ERC-721 token which controls the account
    /// if the token exists.
    ///
    /// This is value is obtained by calling `ownerOf` on the ERC-721 contract.
    ///
    /// @return Address of the owner of the ERC-721 token which owns the account
    function owner() external view returns (address);

    /// @dev Returns a nonce value that is updated on every successful transaction
    ///
    /// @return The current account nonce
    function nonce() external view returns (uint256);
}


这里是一个已经实现的合约钱包示例。

应用案例

Sapienz NFT 系列使用了 ERC 6551 标准,我们来看看它是怎样应用 6551 的,代码在此

我们主要来看其中的 _mintWithTokens 函数,它的作用是,用户需要使用一些白名单的 NFT(例如 PIGEON)来 mint Sapienz NFT。具体逻辑是,函数将用户拥有的这些白名单的 NFT 转入到即将生成的 Sapienz NFT 对应的合约钱包中(即 token bound accounts),然后再为用户 mint Sapienz NFT。也就是说这些转入的白名单 NFT 是归属于生成的 Sapienz NFT 的。

我们来看看主要代码:

function _mintWithTokens(
    address tokenAddress,
    uint256[] memory tokenIds,
    bytes32[][] memory proof,
    address recipient
) internal {
    // ...
    uint256 quantity = tokenIds.length;

    for (uint256 i = 0; i < quantity; i++) {
        uint256 tokenId = tokenIds[i];

        // 计算出 当前 tokenId(是当前 Sapienz 合约的 tokenId,不是传入的 tokenId)所有对应 tba 的地址
        address tba = erc6551Registry.account(
            erc6551AccountImplementation,
            block.chainid,
            address(this),
            startTokenId + i,
            0
        );

        // ....

        // 将白名单 NFT#tokenId 从 msg.sender 转移到 tba 中
        IERC721Upgradeable(tokenAddress).safeTransferFrom(
            msg.sender,
            tba,
            tokenId
        );
    }

    _safeMint(recipient, quantity);
}


我们在前面的 IERC6551Registry 接口中看到,account 函数是用来计算生成的合约钱包地址的,即代码中的 tba 就是合约钱包的地址,然后在后面会通过 tokenAddresssafeTransferFrom 函数将白名单 NFT 转入 tba 中。

我们注意到,代码中仅仅是计算出了 tba 的合约地址,但是并没有部署合约这一步操作,也就是说,这里的转入操作的目标地址可能是一个没有实际部署代码的地址,仅仅是一个预留地址而已。例如这个 tba,它是拥有 NFT 的,但是却并没有合约代码(不过在你看到的时候可能已经发生了状态变化)。如果我们希望该地址是有合约代码的话,需要调用 Registry 的 createAccount 函数来创建部署合约。例如这个 tba 就是已经部署的合约地址。

其中字节码的前半部分是 EIP-1167 的拼接字节码,后面的阴影部分是我们前面提到的 getCreationCode 函数中拼接的salt_chainId_tokenContract_tokenId_ 数据。

总结

我们通过原理,代码实现和实际案例学习了 ERC-6551,它本身内容并不复杂,提供了一个创建 NFT 钱包的新思路。我觉得它最友好的一点就是可以向前兼容 NFT,没有侵入性,这样使得 NFT 本身也不用去关心 6551 的逻辑,只要按照自己的业务开发就行。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.