Alert Source Discuss
🚧 Stagnant Standards Track: ERC

ERC-5753: EIP-721 的可锁定扩展

用于禁用 token 转移(锁定)和重新启用它们(解锁)的接口。

Authors Filipp Makarov (@filmakarov)
Created 2022-10-05
Discussion Link https://ethereum-magicians.org/t/lockable-nfts-extension/8800
Requires EIP-165, EIP-721

摘要

此标准是 EIP-721 的扩展。它引入了可锁定的 NFT。锁定的资产可以以任何方式使用,除了出售和/或转移它。所有者或操作者可以锁定 token。当 token 被锁定时,解锁者地址(EOA 或合约)会被设置。只有解锁者能够 unlock 该 token。

动机

有了 NFT,数字对象变成了数字商品,这些数字商品是可以验证地拥有的,易于交易的,并且可以永久地存储在区块链上。这就是为什么不断改进 non-fungible token 的用户体验非常重要,而不仅仅是从其中一种 fungible token 那里继承它。

在 DeFi 中,存在一种 UX 模式,您可以在服务智能合约上锁定您的 token。例如,如果您想借一些 $DAI,您必须提供一些 $ETH 作为贷款的抵押品。在贷款期间,此 $ETH 被锁定到贷款服务合约中。这种模式适用于 $ETH 和其他 fungible token。

然而,对于 NFT 来说,情况应该有所不同,因为 NFT 有很多用例,要求 NFT 即使在它被用作贷款抵押品时也保留在持有者的钱包中。您可能希望继续使用您的 NFT 作为 Twitter 上的经过验证的 PFP,或者使用它通过 collab.land 授权 Discord 服务器。您可能希望在 P2E 游戏中使用您的 NFT。即使在贷款期间,您也应该能够做到所有这些,就像您即使房子被抵押也能够住在房子里一样。

以下用例针对可锁定的 NFT 启用:

  • NFT 抵押贷款 使用您的 NFT 作为贷款的抵押品,而无需将其锁定在贷款协议合约上。而是将其锁定在您的钱包中,并继续享受您的 NFT 的所有效用。
  • NFT 的无抵押租赁 借用 NFT 并支付费用,而无需巨额抵押品。您可以使用 NFT,但不能转移它,因此贷方是安全的。借用服务合约会在借用期满后立即将 NFT 转回给贷方。
  • 首次销售 仅支付部分价格来 Mint NFT,并在您对集合的演变方式感到满意时支付剩余部分。
  • 二级销售 分期购买和出售您的 NFT。买方获得锁定的 NFT 并立即开始使用它。与此同时,在支付所有分期付款之前,他/她无法出售 NFT。如果未收到全额付款,则 NFT 会连同费用一起返回给卖方。
  • S 代表安全 安全方便地使用您独有的蓝筹 NFT。使用 NFT 最方便的方式是与 MetaMask 一起使用。但是,MetaMask 容易受到各种错误和攻击的影响。使用 Lockable 扩展程序,您可以锁定您的 NFT 并将您的安全冷钱包声明为解锁器。因此,您仍然可以将您的 NFT 保留在 MetaMask 上并方便地使用它。即使黑客可以访问您的 MetaMask,他们也无法在没有访问冷钱包的情况下转移您的 NFT。这就是使 Lockable NFT 安全的原因。
  • 元宇宙就绪 锁定 NFT 门票在大型元宇宙活动期间可能很有用。这将阻止已经使用 NFT 登录的用户出售它或将其转移给另一个用户。因此,我们避免了一张票的双重使用。
  • 非托管 Staking 社区(如 Cyber​​Kongz、Moonbirds 等)提出了不同的非托管 Staking 方法。本实现中建议的方法假定 token 只能在一个地方进行 Staking,而不能同时在多个地方进行 Staking(就像您不能同时将钱存入两个银行帐户一样)。而且它不需要任何额外的代码,并且仅通过锁定功能即可使用。 同一概念的另一种方法是使用锁定来提供 HODL 的证明。您可以锁定您的 NFT 以防止出售,以此来表达对社区的忠诚度,并开始为此获得奖励。这是奖励机制的更好版本,该机制最初由 The Hashmasks 及其 $NCT token 引入。
  • 安全方便的共同所有权和共同使用 安全共同所有权和共同使用的扩展。例如,您想与朋友一起购买昂贵的 NFT 资产,但是使用多重签名不是很方便,因此您可以在钱包之间安全地轮换和使用它。NFT 将存储在其中一位共同所有者的钱包中,并且他将能够以任何方式使用它(转移除外),而无需多重批准。转移将需要多重批准。

规范

本文档中的关键词“必须(MUST)”、“禁止(MUST NOT)”、“必需(REQUIRED)”、“应该(SHALL)”、“不应该(SHALL NOT)”、“推荐(SHOULD)”、“不推荐(SHOULD NOT)”、“可以(MAY)”和“可选(OPTIONAL)”应按照 RFC 2119 中的描述进行解释。

符合 EIP-721 的合约 MAY 实现此 EIP,以提供在其当前所有者地址锁定和解锁 token 的标准方法。 如果 token 被锁定,则 getLocked 函数 MUST 返回一个能够解锁 token 的地址。 对于未锁定的 token,getLocked 函数 MUST 返回 address(0)。 用户 MAY 通过调用 lock(address(1), tokenId) 永久锁定该 token。

当 token 被锁定时,所有 EIP-721 转移函数 MUST 恢复,除非该交易是由解锁器发起的。 当 token 被锁定时,EIP-721 approve 方法 MUST 恢复此 token。 当 token 被锁定时,EIP-721 getApproved 方法 SHOULD 为此 token 返回 unlocker 地址,以便解锁器能够转移此 token。 当锁定的 token 由解锁器转移时,token MUST 在转移后解锁。

市场应调用 EIP-721 Lockable token 合约的 getLocked 方法,以了解具有指定 tokenId 的 token 是否被锁定。锁定的 token SHOULD NOT 可用于列表。锁定的 token 无法出售。因此,市场 SHOULD 隐藏已锁定 token 的列表,因为此类订单无法完成。

合约接口

pragma solidity >=0.8.0;

/// @dev Interface for the Lockable extension
/// @dev Lockable 扩展的接口

interface ILockable {

    /**
     * @dev Emitted when `id` token is locked, and `unlocker` is stated as unlocking wallet.
     * @dev 当 `id` token 被锁定并且 `unlocker` 被声明为解锁钱包时发出。
     */
    event Lock (address indexed unlocker, uint256 indexed id);

    /**
     * @dev Emitted when `id` token is unlocked.
     * @dev 当 `id` token 被解锁时发出。
     */
    event Unlock (uint256 indexed id);

    /**
     * @dev Locks the `id` token and gives the `unlocker` address permission to unlock.
     * @dev 锁定 `id` token 并授予 `unlocker` 地址解锁权限。
     */
    function lock(address unlocker, uint256 id) external;

    /**
     * @dev Unlocks the `id` token.
     * @dev 解锁 `id` token。
     */
    function unlock(uint256 id) external;

    /**
     * @dev Returns the wallet, that is stated as unlocking wallet for the `tokenId` token.
     * If address(0) returned, that means token is not locked. Any other result means token is locked.
     * @dev 返回声明为 `tokenId` token 的解锁钱包的钱包。
     * 如果返回 address(0),则表示 token 未锁定。任何其他结果都表示 token 已锁定。
     */
    function getLocked(uint256 tokenId) external view returns (address);

}

当使用 0x72b68110 调用 supportsInterface 方法时,它 MUST 返回 true

原理

这种方法提出了一种旨在尽可能精简的解决方案。它仅允许锁定项目(声明谁将能够解锁它)并在需要时在用户有权执行此操作时将其解锁。

同时,它是一种通用的实现。它允许大量的可扩展性以及动机部分中提到的任何潜在用例(或所有用例)。

当需要授予 token 的临时和/或可赎回权利(租赁、分期购买)时,此 EIP 涉及将 token 实际转移到临时用户的钱包,而不仅仅是分配角色。 做出此选择是为了提高与所有现有 NFT 生态系统工具和 dApp(如 Collab.land)的兼容性。否则,它将要求所有此类 dApp 实现其他接口和逻辑。

函数和存储实体的命名和参考实现模仿 [EIP-721] 的 Approval 流程,以便直观。

向后兼容性

此标准与当前的 EIP-721 标准兼容。

参考实现

// SPDX-License-Identifier: CC0-1.0
pragma solidity >=0.8.0;

import '../ILockable.sol';
import '@openzeppelin/contracts/token/ERC721/ERC721.sol';

/// @title Lockable Extension for ERC721
/// @title ERC721 的可锁定扩展

abstract contract ERC721Lockable is ERC721, ILockable {

    /*///////////////////////////////////////////////////////////////
                            LOCKABLE EXTENSION STORAGE                        
    //////////////////////////////////////////////////////////////*/

    mapping(uint256 => address) internal unlockers;

    /*///////////////////////////////////////////////////////////////
                              LOCKABLE LOGIC
    //////////////////////////////////////////////////////////////*/

    /**
     * @dev Public function to lock the token. Verifies if the msg.sender is the owner
     *      or approved party.
     * @dev 用于锁定 token 的公共函数。验证 msg.sender 是否为所有者
     *      或批准方。
     */

    function lock(address unlocker, uint256 id) public virtual {
        address tokenOwner = ownerOf(id);
        require(msg.sender == tokenOwner || isApprovedForAll(tokenOwner, msg.sender)
        , "NOT_AUTHORIZED");
        require(unlockers[id] == address(0), "ALREADY_LOCKED"); 
        unlockers[id] = unlocker;
        _approve(unlocker, id);
    }

    /**
     * @dev Public function to unlock the token. Only the unlocker (stated at the time of locking) can unlock
     * @dev 用于解锁 token 的公共函数。只有解锁器(在锁定时声明)可以解锁
     */
    function unlock(uint256 id) public virtual {
        require(msg.sender == unlockers[id], "NOT_UNLOCKER");
        unlockers[id] = address(0);
    }

    /**
     * @dev Returns the unlocker for the tokenId
     *      address(0) means token is not locked
     *      reverts if token does not exist
     * @dev 返回 tokenId 的解锁器
     *      address(0) 表示 token 未锁定
     *      如果 token 不存在则恢复
     */
    function getLocked(uint256 tokenId) public virtual view returns (address) {
        require(_exists(tokenId), "Lockable: locking query for nonexistent token");
        return unlockers[tokenId];
    }

    /**
     * @dev Locks the token
     * @dev 锁定 token
     */
    function _lock(address unlocker, uint256 id) internal virtual {
        unlockers[id] = unlocker;
    }

    /**
     * @dev Unlocks the token
     * @dev 解锁 token
     */
    function _unlock(uint256 id) internal virtual {
        unlockers[id] = address(0);
    }

    /*///////////////////////////////////////////////////////////////
                              OVERRIDES
    //////////////////////////////////////////////////////////////*/

    function approve(address to, uint256 tokenId) public virtual override {
        require (getLocked(tokenId) == address(0), "Can not approve locked token");
        super.approve(to, tokenId);
    }

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal virtual override {
        // if it is a Transfer or Burn
        // 如果是转移或销毁
        if (from != address(0)) { 
            // token should not be locked or msg.sender should be unlocker to do that
            // token 不应被锁定,或者 msg.sender 应该是解锁器才能执行此操作
            require(getLocked(tokenId) == address(0) || msg.sender == getLocked(tokenId), "LOCKED");
        }
    }

    function _afterTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal virtual override {
        // if it is a Transfer or Burn, we always deal with one token, that is startTokenId
        // 如果是转移或销毁,我们始终处理一个 token,即 startTokenId
        if (from != address(0)) { 
            // clear locks
            // 清除锁
            delete unlockers[tokenId];
        }
    }

    /**
     * @dev Optional override, if to clear approvals while the tken is locked
     * @dev 可选的覆盖,如果在锁定 tken 时清除批准
     */
    function getApproved(uint256 tokenId) public view virtual override returns (address) {
        if (getLocked(tokenId) != address(0)) {
            return address(0);
        }
        return super.getApproved(tokenId);
    }

    /*///////////////////////////////////////////////////////////////
                              ERC165 LOGIC
    //////////////////////////////////////////////////////////////*/

    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override
        returns (bool)
    {
        return
            interfaceId == type(IERC721Lockable).interfaceId ||
            super.supportsInterface(interfaceId);
    }

}

安全考虑

对于管理 EIP-721 token 的合约,没有与此标准的实现直接相关的安全考虑因素。

使用可锁定 token 的合约的注意事项

  • 确保每个声明为 unlocker 的合约实际上可以在所有情况下解锁 token。
  • 有一些用例涉及将 token 转移给临时所有者,然后将其锁定。例如,NFT 租赁。管理此类服务的智能合约应始终使用 transferFrom 而不是 safeTransferFrom 以避免重入。
  • 关于可锁定 token 没有 MEV 方面的考虑,因为只有授权方才被允许锁定和解锁。

版权

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

Citation

Please cite this document as:

Filipp Makarov (@filmakarov), "ERC-5753: EIP-721 的可锁定扩展 [DRAFT]," Ethereum Improvement Proposals, no. 5753, October 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5753.