はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、NFTをロックしてtransferの制限を行う仕組みを提案しているERC5753についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIPについてまとめています。

概要

ERC5753は、ERC721を拡張してNFTにロック機能を追加する提案です。 ロックされたNFTは、売却や譲渡はできませんが、それ以外の用途には自由に使えます。 NFTの所有者やオペレーターがトークンをロックでき、ロック時には「アンロックできるアドレス」(EOAやスマートコントラクト)を指定します。 解除できるのは、その「アンロックできるアドレス」のみです。

動機

NFTはデジタルアイテムをデジタル資産として扱えるようにし、所有権を明確にしながらブロックチェーン上で安全に管理・取引できる仕組みです。 これらの特性を活かしつつ、NFTの利便性を向上させることが重要です。

DeFiでは、トークンをロックするUXパターンが一般的です。 例えば、ETHを担保にしてローンを借りる場合、ETHはローン期間中コントラクトにロックされます。 しかし、NFTの場合、ローンの担保として利用されながらも、所有者のアドレスが保持しているケースが多いです。

例えば、TwitterでのPFP(プロフィール画像)認証、Discordのコラボランドによる認証、P2Eゲームでの活用など、NFTは多様な用途があります。 家を担保に入れても住み続けられるように、NFTも担保としてロックされても引き続き使えるべきです。

ロック機能の活用例

NFTを担保とするローン

NFTを貸し手のコントラクトに預けることなく、担保として利用できます。 NFTは所有者のアドレスにロックされ、譲渡や売却はできないですがその他の用途には問題なく使えます。

担保不要のNFTレンタル

NFTを貸し出す時に担保を求める必要がなくなります。 借り手はNFTを利用できますが譲渡はできません。 レンタル期間が終了すると、自動的にNFTが貸し手に返還される仕組みです。

一部前払いのMint販売

NFTをMintする時に、全額を即時支払うのではなく一部の価格だけ先に支払い、残額を後払いする形をとれます。

分割払いでのNFT購入

NFTを分割払いで購入できるようになります。 購入者は支払いが完了する前でもNFTを使用できますが、完済するまで売却はできません。 支払いが完了しなかった場合、NFTは売り手に返還されます。

セキュリティ対策

高価なNFTを安全に保管できます。 例えば、MetaMaskにNFTを保管しつつ、アンロック権限をコールドウォレットに設定することで、MetaMaskがハッキングされてもNFTが盗まれにくくなります。

メタバースでの利用

メタバースイベントのチケットNFTにロックをかけることで、ログイン後の譲渡を防げます。 これにより、1つのチケットを複数人が使うことを防止できます。

非カストディアルなステーキング

NFTを複数のサービスに同時にステーキングすることを防ぎます。 これにより、1つのNFTを特定の場所にのみステーキングできるようになります。 また、NFTをロックすることで「HODL(長期保有)」の証明として報酬を得る仕組みも実現できます。

安全で便利な共同所有・共同利用

複数人でNFTを所有しながらスムーズに利用できます。 マルチシグ(複数人での署名)を使わなくても、NFTを1人の共同所有者のウォレットに保管し、譲渡以外の用途には自由に使用できるようになります。 譲渡時には共同所有者の承認が必要となるためより安全な管理が可能です。

仕様

ERC5753は、NFTにロック機能を追加する拡張仕様です。 ERC721に準拠するコントラクトは、この仕様を実装することでNFTを所有者のアドレス上でロック・アンロックできる標準的なメソッドを提供できます。

ILockable.sol
pragma solidity >=0.8.0;

/// @dev Interface for the Lockable extension

interface ILockable {

    /**
     * @dev Emitted when `id` token is locked, and `unlocker` is stated as unlocking wallet.
     */
    event Lock (address indexed unlocker, uint256 indexed id);

    /**
     * @dev Emitted when `id` token is unlocked.
     */
    event Unlock (uint256 indexed id);

    /**
     * @dev Locks the `id` token and gives the `unlocker` address permission to unlock.
     */
    function lock(address unlocker, uint256 id) external;

    /**
     * @dev Unlocks the `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.
     */
    function getLocked(uint256 tokenId) external view returns (address);

}
  • getLocked 関数は、トークンがロックされている場合、アンロックできるアドレスを返します。
    • ロックされていない場合は address(0) を返します。
  • lock(address(1), tokenId) を呼び出すことで、トークンを永久にロックすることもできます。
  • トークンがロックされると、ERC721のtransfer系の関数は全て失敗(revert)します。
    • ただし、アンロック権限を持つアドレスからのtransferは例外です。
  • ロック中のトークンはERC721の approve メソッドの呼び出しも失敗します。
  • getApproved メソッドは、ロック中のトークンに対してアンロック権限を持つアドレスを返すことが推奨されます。
  • ロックされているトークンに対して、再び lock を呼ぶことは許可されません(同じアンロッカーを指定しても失敗します)。
  • アンロッカーがトークンをtransferした場合、transfer後にロックは解除されます。

マーケットプレイスとの関係

マーケットプレイスは、NFTがロックされているかどうかを確認するために、getLocked メソッドを呼び出すべきです。
また、ロックされたNFTは売却不可のため、マーケットプレイスはロック中のNFTのリストを非表示にすることが推奨されます。
ロックされたNFTの売買オーダーは成立しないため、リストに表示しないようにするのが望ましいです。

コントラクトのインターフェース

この仕様で定義されている ILockable インターフェースは、以下のようなメソッドとイベントを持っています。

イベント

  • Lock(address unlocker, uint256 id)

NFTがロックされたときに発行され、アンロッカーのアドレスを記録します。

  • Unlock(uint256 id)

FTがアンロックされたときに発行されます。

関数

  • lock(address unlocker, uint256 id) external;

指定したNFT(id)をロックし、指定アドレス(unlocker)にアンロック権限を付与します。

  • unlock(uint256 id) external;

指定したNFT(id)のロックを解除します。

  • getLocked(uint256 tokenId) external view returns (address);

指定したNFT(tokenId)のロック状態を取得します。
address(0) を返す場合は未ロック、それ以外のアドレスが返る場合はロック中です。

インターフェースの識別

このインターフェースは、supportsInterface メソッドを実装し、0x72b68110 を渡したときに true を返す必要があります。 これは、コントラクトが ILockable をサポートしていることを示す識別子です。

補足

ERC5753は、できるだけシンプルな設計を目指しています。 基本的な機能として、NFTのロックとアンロックのみを提供し、ロック時には「誰がアンロックできるか」を指定できるようにしています。 これにより、最小限のコードで最大限の汎用性を実現しています。

汎用的な設計

ERC5753は、特定の用途に限定されずさまざまなユースケースに対応できる汎用的な設計になっています。 例えば、NFTを担保にするローン、分割払いでの購入、NFTレンタルなど、あらゆるケースに対応できます。 必要な機能はロックとアンロックのみで、それをどのように活用するかはユーザーやdApp次第です。

一時的な所有権の付与と互換性

レンタルや分割払いのような一時的な権利付与が必要な場合、ERC5753ではNFTを実際に一時所有者のウォレットに移動させる方法を採用しています。 ただ単に「権限を付与する」だけではなく、トークン自体が一時的に移動するのがポイントです。

この方法を採用した理由は、既存のNFTエコシステムとの互換性を高めるためです。 例えば、NFTの所有情報を使ってアクセス制御をするCollab.landのようなサービスでは、新たな仕組みに対応するために追加の実装が必要になります。 しかし、NFTを実際に移動させることで、特別な対応をせずとも既存のツールと連携できるようになります。

直感的な設計

関数やストレージの設計は、ERC721の「承認(Approval)」の流れを模倣しています。 これにより、NFT開発者にとって直感的で理解しやすい設計になっています。 ERC721に慣れている開発者なら、すぐにERC5753のロジックを理解て、活用できます。

互換性

ERC721と完全に互換性があります。

参考実装

ERC721Lockable.sol
// 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

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.
     */

    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
     */
    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
     */
    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
     */
    function _lock(address unlocker, uint256 id) internal virtual {
        unlockers[id] = unlocker;
    }

    /**
     * @dev Unlocks the 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
            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
        if (from != address(0)) { 
            // clear locks
            delete unlockers[tokenId];
        }
    }

    /**
     * @dev Optional override, if to clear approvals while the tken is locked
     */
    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);
    }

}

セキュリティ

ERC5753を実装するERC721トークン管理コントラクト自体には、特別なセキュリティリスクはありません。 ただし、ロック可能なNFTを扱うスマートコントラクトに関しては、いくつかの注意点があります。

アンロック権限の管理

NFTのアンロックを管理するコントラクトは、全ての状況でトークンを確実にアンロックできることを保証する必要があります。 意図しない状態でトークンがロックされたままになると、NFTの所有者や関係者が不利益を被る可能性があります。 そのため、ロック機能を扱うコントラクトでは、適切なアンロック処理が実装されていることを確認するべきです。

一時的な所有権の移動におけるリスク

NFTのレンタルなどのケースでは、NFTを一時的に別のウォレットへ移動させた後にロックすることがあります。 このようなサービスを提供するコントラクトでは、NFTのtransferに transferFrom を使用することが推奨されます。

safeTransferFrom は、トークンを受け取る側のコントラクトが onERC721Received を実装しているかを確認する処理が含まれており、再入可能性(re-entrancy attack)のリスクを高める可能性があります。 これを避けるために、NFTレンタルなどの用途では transferFrom を使用し、適切なセキュリティ対策を講じることが重要です。

MEV(最大抽出可能価値)への影響

ロック可能なNFTに関しては、MEV(Maximal Extractable Value)の影響は特に考慮する必要はありません。 ロックとアンロックの操作は指定された権限を持つアドレスのみが実行できるため、第三者が意図しない形でNFTのロック状態を操作することはできません。 そのため、MEVの影響を受ける可能性は低いと考えられます。

引用

Filipp Makarov (@filmakarov), "ERC-5753: Lockable Extension for EIP-721 [DRAFT]," Ethereum Improvement Proposals, no. 5753, October 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5753.

最後に

今回は「NFTをロックしてtransferの制限を行う仕組みを提案しているERC5753」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!