はじめに

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

今回は、NFTにロック機能を適用する仕組みを提案しているERC5058についてまとめていきます!

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

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

概要

ERC5058は、NFTの標準規格であるERC721に「ロック機能」を拡張機能として追加する提案です。 NFTの所有者は、指定したオペレーターに対してロックを許可し、そのオペレーターがNFTをロックします。 ロックされたNFTは、設定された期間が終了するまでtransferできなくなります。

この機能を使えば、NFTを別のコントラクトに預けずユーザーのウォレット内に保持したまま、他のコントラクトと連携することができます。

動機

NFTの標準規格であるERC721に、ネイティブなロック機構を追加する提案がされています。 ERC5058では、NFTを一時的に譲渡不可にしながらも、所有者のウォレットに保持し続けることが可能になります。

NFTにロック機構を追加

ERC5058では、NFTの所有者が第三者(オペレーター)に対してロック権限を与えることができます。 具体的には、以下のような流れで機能します。

  • 所有者は setLockApprovalForAll() または lockApprove() を使って、NFTをロックするオペレーターを承認します。
  • 承認されたオペレーターは lock() を実行してNFTをロックします。
  • ロックされたNFTは、あらかじめ決められた期間が終了するまでtransferできません。

ただし、ロック中であっても、NFTは所有者のウォレットに保管されたままになります。

ロック機構の必要性

NFTの市場は急速に拡大しており、有名なNFTコレクション(いわゆるブルーチップNFT)も多数登場しています。 しかし、NFTの大きな課題のひとつが「流動性の低さ」です。

NFTを担保にして資金を借りるプロジェクト(例えばNFTFiやBendDAO)は、これまでにも存在しましたが、共通してNFTの所有権をコントラクトに移す必要がありました。 この方法には以下のようなリスクがあります。

  • スマートコントラクトのリスク
     コントラクトに脆弱性やバグがあった場合、NFTが失われる可能性があります。

  • ユーティリティの喪失
     NFTがウォレットに表示されなくなることで、プロフィール画像として使えないなどの実用性が失われます。

  • エアドロップの受取不可
     NFTの所有者に対して配布されるトークン(エアドロップ)が、正しく受け取れなくなります。

こうした問題はユーザー体験を損ない、NFTを活用した金融サービス(NFTFi)への参加を妨げる原因となっています。

ロック機構がもたらす改善

ERC5058で提案されているロック機構では、NFTの所有権を譲渡することなく以下のような状態を実現できます。

  • NFTはウォレット内にとどまり続けるため、コントラクトに預ける必要がない。
  • ロック中はtransferが禁止されるが、それ以外のNFTの機能(表示やメタデータの利用など)はそのまま使える。
  • 所有者は引き続き自分でエアドロップを受け取ることができる。

このように、NFTをロックすることで、コレクションとしての資産価値と、他プロジェクトでの活用(ユーティリティ)を両立できます。

期待される活用例と効果

ロック機構は、NFTFiを中心としたさまざまなユースケースに対応する基盤となります。 具体的には、以下のような用途に応用可能です。

  • ステーキング
  • 貸出(レンディング)
  • クラウドファンディング参加

NFTの移動を行わず、ウォレット内でロック状態を管理できることで、所有者が安心して金融商品としてNFTを活用できるようになります。 この仕組みにより、NFTFiへの参加が促進され、NFTエコシステム全体の健全な成長が期待されます。

仕様

インターフェース

IERC5058.sol
// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.8;

/**
 * @dev EIP-721 Non-Fungible Token Standard, optional lockable extension
 * ERC721 Token that can be locked for a certain period and cannot be transferred.
 * This is designed for a non-escrow staking contract that comes later to lock a user's NFT
 * while still letting them keep it in their wallet.
 * This extension can ensure the security of user tokens during the staking period.
 * If the nft lending protocol is compatible with this extension, the trouble caused by the NFT
 * airdrop can be avoided, because the airdrop is still in the user's wallet
 */
interface IERC5058 {
    /**
     * @dev Emitted when `tokenId` token is locked by `operator` from `from`.
     */
    event Locked(address indexed operator, address indexed from, uint256 indexed tokenId, uint256 expired);

    /**
     * @dev Emitted when `tokenId` token is unlocked by `operator` from `from`.
     */
    event Unlocked(address indexed operator, address indexed from, uint256 indexed tokenId);

    /**
     * @dev Emitted when `owner` enables `approved` to lock the `tokenId` token.
     */
    event LockApproval(address indexed owner, address indexed approved, uint256 indexed tokenId);

    /**
     * @dev Emitted when `owner` enables or disables (`approved`) `operator` to lock all of its tokens.
     */
    event LockApprovalForAll(address indexed owner, address indexed operator, bool approved);

    /**
     * @dev Returns the locker who is locking the `tokenId` token.
     *
     * Requirements:
     *
     * - `tokenId` must exist.
     */
    function lockerOf(uint256 tokenId) external view returns (address locker);

    /**
     * @dev Lock `tokenId` token until the block number is greater than `expired` to be unlocked.
     *
     * Requirements:
     *
     * - `tokenId` token must be owned by `owner`.
     * - `expired` must be greater than block.number
     * - If the caller is not `owner`, it must be approved to lock this token
     * by either {lockApprove} or {setLockApprovalForAll}.
     *
     * Emits a {Locked} event.
     */
    function lock(uint256 tokenId, uint256 expired) external;

    /**
     * @dev Unlock `tokenId` token.
     *
     * Requirements:
     *
     * - `tokenId` token must be owned by `owner`.
     * - the caller must be the operator who locks the token by {lock}
     *
     * Emits a {Unlocked} event.
     */
    function unlock(uint256 tokenId) external;

    /**
     * @dev Gives permission to `to` to lock `tokenId` token.
     *
     * Requirements:
     *
     * - The caller must own the token or be an approved lock operator.
     * - `tokenId` must exist.
     *
     * Emits an {LockApproval} event.
     */
    function lockApprove(address to, uint256 tokenId) external;

    /**
     * @dev Approve or remove `operator` as an lock operator for the caller.
     * Operators can call {lock} for any token owned by the caller.
     *
     * Requirements:
     *
     * - The `operator` cannot be the caller.
     *
     * Emits an {LockApprovalForAll} event.
     */
    function setLockApprovalForAll(address operator, bool approved) external;

    /**
     * @dev Returns the account lock approved for `tokenId` token.
     *
     * Requirements:
     *
     * - `tokenId` must exist.
     */
    function getLockApproved(uint256 tokenId) external view returns (address operator);

    /**
     * @dev Returns if the `operator` is allowed to lock all of the assets of `owner`.
     *
     * See {setLockApprovalForAll}
     */
    function isLockApprovedForAll(address owner, address operator) external view returns (bool);

    /**
     * @dev Returns if the `tokenId` token is locked.
     */
    function isLocked(uint256 tokenId) external view returns (bool);

    /**
     * @dev Returns the `tokenId` token lock expired time.
     */
    function lockExpiredTime(uint256 tokenId) external view returns (uint256);
}

ERC5058は、ERC721に対して「transferを一時的に禁止するロック機構」を追加するための拡張仕様です。 提案されたインターフェース IERC5058 を実装することで、NFTをウォレットに保持したまま外部からの操作を制限できるようになります。 これは、非エスクロー型のステーキングやレンディングといったユースケースを想定した設計です。

このロック機構は、ユーザーがNFTを他のプロジェクトに参加させる時に、自分のウォレット内にNFTを保持したままロックできるようにしつつ、エアドロップの受け取りや表示といったことができるようになります。

イベント

IERC5058 は、ロックやアンロック、承認の処理が実行された時に以下のイベントを発行します。

Locked

NFTがロックされた時に発行されるイベント。 ロックしたオペレーター、所有者、トークンID、ロックの終了ブロック番号が記録されます。

Unlocked

NFTがアンロックされた時に発行されるイベント。

LockApproval

特定のNFTに対してロック操作の承認が行われた時に発行されるイベント。

LockApprovalForAll

所有者が全トークンに対してオペレーターをロック権限付きで承認または解除した時に発行されるイベント。

関数

lock

NFTをロック状態にする関数。 ロックはブロック番号で期限を指定し、期限が過ぎるまでトークンのtransferを禁止します。 実行には以下の条件を満たす必要があります。

  • 呼び出し元がNFTの所有者、lockApprove、setLockApprovalForAll によって事前に承認されたオペレーターであること。
  • expired に指定されたブロック番号が現在の block.number よりも大きいこと。

unlock

NFTのロックを解除する関数。 ロック操作を実行したオペレーター自身が解除する必要があります。 所有者であっても、ロックをかけたオペレーター以外はアンロックできません。

lockApprove

指定されたアドレスに、特定のNFTに対するロック権限を与える関数。 この関数の実行は、NFTの所有者、またはロックオペレーターによってのみ可能です。

setLockApprovalForAll

あるアドレスに対して、呼び出し元のすべてのNFTに対するロック操作の権限をまとめて与える、または解除する関数。 setApprovalForAll に相当するロック版の関数です。

lockerOf

指定されたNFTを現在ロックしているオペレーターのアドレスを返す関数。 NFTがロックされていない場合はゼロアドレスを返します。

getLockApproved

特定のNFTに対して、ロック権限が与えられているアドレスを返す関数。

isLockApprovedForAll

指定されたオペレーターが、所有者のすべてのNFTに対してロック操作を行えるか返す関数。

isLocked

NFTが現在ロック中かどうかを確認できる関数。 ロックされていれば true を返します。

lockExpiredTime

NFTがロックされている場合、そのロックの終了ブロック番号を返す関数。

セキュリティとユースケース

ERC5058の大きな特徴は、「NFTの自己保管を維持しながら、ロックを通じて一時的に移動制限をかけることができる」点にあります。 これにより、ステーキングや貸し出しに使われるNFTがコントラクトに預けられる必要がなくなり、エアドロップの受け取りやウォレット内での所有証明が維持されます。

また、アンロック権限をロックしたオペレーターに限定する設計により、不正な解除を防ぎNFTの安全性が保たれます。

補足

ロック権限の付与

NFTの所有者は、信頼できる外部コントラクトやアカウントに対して、NFTをロックする権限を委譲できます。
その方法として、lockApprove() と setLockApprovalForAll() の2種類が用意されています。

  • lockApprove() は個別のNFTに対してロック権限を与える関数です。
  • setLockApprovalForAll() は、ウォレット内の全てのNFTに対して一括でロック権限を与えます。

NFTFiプロジェクトでは、これらの関数で事前にロック権限を得た上で、lock() を実行してNFTをロックします。 ロックされたNFTは所有者のウォレットに保持されたままとなり、transferのみが禁止されます。

ロックとアンロックの制御

ロックされたNFTは、指定されたブロック番号に達するまでtransferできません。 ただし、ロックしたオペレーター(通常はプロジェクトのコントラクト)が unlock() を使えば、ロック期間中であっても期限よりも早く解除することが可能です。 なお、アンロックできるのは、ロックを実行したアドレス(オペレーター)のみです。 所有者や他のアカウントが解除することはできません。

ロック期間の仕様

NFTをロックする時には、期限となるブロック番号(expired)を指定する必要があります。 このブロック番号は、現在の block.number よりも大きくなければなりません。 ロック期間が終了すると、自動的にロックが解除されてtransferできる状態に戻ります。 これにより、明示的にアンロック処理を実行しなくても、NFTが元の状態に戻ります。

Bound NFTという拡張構想

ERC5058のロック機構を活用した応用として「Bound NFT」というアイデアがあります。 これは、NFTをロックしている間だけ発行される一時的なNFTです。

  • メタデータは元のNFTと同一で、transferも可能です。
  • ロック期間が終了し、元のNFTがアンロックされると自動的に破棄されます。

Bound NFTは、貸出用NFTとして機能したり、コントラクトでのステーキング資格情報として利用できます。 例えば、ユーザーは自身のNFTをロックし、その代わりにBound NFTを受け取って、別のプロジェクトでそれを使って操作するといった形が可能になります。

Bound NFT Factoryの役割

Bound NFT Factoryは、元のNFTに対応するバウンドNFTのコントラクトを生成するためのファクトリコントラクトです。 Uniswapのトークンペア生成に似た仕組みで、create2 を使って、NFTに紐づくコントラクトアドレスをあらかじめ決定できるようになっています。

このファクトリコントラクトによって生成されたBound NFTコントラクトは、対応する元NFTのコントラクト以外からは制御できません。 これにより、Bound NFTの生成と管理が不正に行われないよう、セキュリティが担保されます。

互換性

ERC5058は、ERC721と互換性があります。

参考実装

ERC5058.sol
// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.0;

import "./IERC5058.sol";

/**
 * @dev Implementation ERC721 Lockable Token
 */
abstract contract ERC5058 is ERC721, IERC5058 {
    // Mapping from token ID to unlock time
    mapping(uint256 => uint256) public lockedTokens;

    // Mapping from token ID to lock approved address
    mapping(uint256 => address) private _lockApprovals;

    // Mapping from owner to lock operator approvals
    mapping(address => mapping(address => bool)) private _lockOperatorApprovals;

    /**
     * @dev See {IERC5058-lockApprove}.
     */
    function lockApprove(address to, uint256 tokenId) public virtual override {
        require(!isLocked(tokenId), "ERC5058: token is locked");
        address owner = ERC721.ownerOf(tokenId);
        require(to != owner, "ERC5058: lock approval to current owner");

        require(
            _msgSender() == owner || isLockApprovedForAll(owner, _msgSender()),
            "ERC5058: lock approve caller is not owner nor approved for all"
        );

        _lockApprove(owner, to, tokenId);
    }

    /**
     * @dev See {IERC5058-getLockApproved}.
     */
    function getLockApproved(uint256 tokenId) public view virtual override returns (address) {
        require(_exists(tokenId), "ERC5058: lock approved query for nonexistent token");

        return _lockApprovals[tokenId];
    }

    /**
     * @dev See {IERC5058-lockerOf}.
     */
    function lockerOf(uint256 tokenId) public view virtual override returns (address) {
        require(_exists(tokenId), "ERC5058: locker query for nonexistent token");
        require(isLocked(tokenId), "ERC5058: locker query for non-locked token");

        return _lockApprovals[tokenId];
    }

    /**
     * @dev See {IERC5058-setLockApprovalForAll}.
     */
    function setLockApprovalForAll(address operator, bool approved) public virtual override {
        _setLockApprovalForAll(_msgSender(), operator, approved);
    }

    /**
     * @dev See {IERC5058-isLockApprovedForAll}.
     */
    function isLockApprovedForAll(address owner, address operator) public view virtual override returns (bool) {
        return _lockOperatorApprovals[owner][operator];
    }

    /**
     * @dev See {IERC5058-isLocked}.
     */
    function isLocked(uint256 tokenId) public view virtual override returns (bool) {
        return lockedTokens[tokenId] > block.number;
    }

    /**
     * @dev See {IERC5058-lockExpiredTime}.
     */
    function lockExpiredTime(uint256 tokenId) public view virtual override returns (uint256) {
        return lockedTokens[tokenId];
    }

    /**
     * @dev See {IERC5058-lock}.
     */
    function lock(uint256 tokenId, uint256 expired) public virtual override {
        //solhint-disable-next-line max-line-length
        require(_isLockApprovedOrOwner(_msgSender(), tokenId), "ERC5058: lock caller is not owner nor approved");
        require(expired > block.number, "ERC5058: expired time must be greater than current block number");
        require(!isLocked(tokenId), "ERC5058: token is locked");

        _lock(_msgSender(), tokenId, expired);
    }

    /**
     * @dev See {IERC5058-unlock}.
     */
    function unlock(uint256 tokenId) public virtual override {
        require(lockerOf(tokenId) == _msgSender(), "ERC5058: unlock caller is not lock operator");

        address from = ERC721.ownerOf(tokenId);

        _beforeTokenLock(_msgSender(), from, tokenId, 0);

        delete lockedTokens[tokenId];

        emit Unlocked(_msgSender(), from, tokenId);

        _afterTokenLock(_msgSender(), from, tokenId, 0);
    }

    /**
     * @dev Locks `tokenId` from `from`  until `expired`.
     *
     * Requirements:
     *
     * - `tokenId` token must be owned by `from`.
     *
     * Emits a {Locked} event.
     */
    function _lock(
        address operator,
        uint256 tokenId,
        uint256 expired
    ) internal virtual {
        address owner = ERC721.ownerOf(tokenId);

        _beforeTokenLock(operator, owner, tokenId, expired);

        lockedTokens[tokenId] = expired;
        _lockApprovals[tokenId] = operator;

        emit Locked(operator, owner, tokenId, expired);

        _afterTokenLock(operator, owner, tokenId, expired);
    }

    /**
     * @dev Safely mints `tokenId` and transfers it to `to`, but the `tokenId` is locked and cannot be transferred.
     *
     * Requirements:
     *
     * - `tokenId` must not exist.
     *
     * Emits {Locked} and {Transfer} event.
     */
    function _safeLockMint(
        address to,
        uint256 tokenId,
        uint256 expired,
        bytes memory _data
    ) internal virtual {
        require(expired > block.number, "ERC5058: lock mint for invalid lock block number");

        _safeMint(to, tokenId, _data);

        _lock(_msgSender(), tokenId, expired);
    }

    /**
     * @dev See {ERC721-_burn}. This override additionally clears the lock approvals for the token.
     */
    function _burn(uint256 tokenId) internal virtual override {
        address owner = ERC721.ownerOf(tokenId);
        super._burn(tokenId);

        _beforeTokenLock(_msgSender(), owner, tokenId, 0);

        // clear lock approvals
        delete lockedTokens[tokenId];
        delete _lockApprovals[tokenId];

        _afterTokenLock(_msgSender(), owner, tokenId, 0);
    }

    /**
     * @dev Approve `to` to lock operate on `tokenId`
     *
     * Emits a {LockApproval} event.
     */
    function _lockApprove(
        address owner,
        address to,
        uint256 tokenId
    ) internal virtual {
        _lockApprovals[tokenId] = to;
        emit LockApproval(owner, to, tokenId);
    }

    /**
     * @dev Approve `operator` to lock operate on all of `owner` tokens
     *
     * Emits a {LockApprovalForAll} event.
     */
    function _setLockApprovalForAll(
        address owner,
        address operator,
        bool approved
    ) internal virtual {
        require(owner != operator, "ERC5058: lock approve to caller");
        _lockOperatorApprovals[owner][operator] = approved;
        emit LockApprovalForAll(owner, operator, approved);
    }

    /**
     * @dev Returns whether `spender` is allowed to lock `tokenId`.
     *
     * Requirements:
     *
     * - `tokenId` must exist.
     */
    function _isLockApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) {
        require(_exists(tokenId), "ERC5058: lock operator query for nonexistent token");
        address owner = ERC721.ownerOf(tokenId);
        return (spender == owner || isLockApprovedForAll(owner, spender) || getLockApproved(tokenId) == spender);
    }

    /**
     * @dev See {ERC721-_beforeTokenTransfer}.
     *
     * Requirements:
     *
     * - the `tokenId` must not be locked.
     */
    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal virtual override {
        super._beforeTokenTransfer(from, to, tokenId);

        require(!isLocked(tokenId), "ERC5058: token transfer while locked");
    }

    /**
     * @dev Hook that is called before any token lock/unlock.
     *
     * Calling conditions:
     *
     * - `owner` is non-zero.
     * - When `expired` is zero, `tokenId` will be unlock for `from`.
     * - When `expired` is non-zero, ``from``'s `tokenId` will be locked.
     *
     */
    function _beforeTokenLock(
        address operator,
        address owner,
        uint256 tokenId,
        uint256 expired
    ) internal virtual {}

    /**
     * @dev Hook that is called after any lock/unlock of tokens.
     *
     * Calling conditions:
     *
     * - `owner` is non-zero.
     * - When `expired` is zero, `tokenId` will be unlock for `from`.
     * - When `expired` is non-zero, ``from``'s `tokenId` will be locked.
     *
     */
    function _afterTokenLock(
        address operator,
        address owner,
        uint256 tokenId,
        uint256 expired
    ) internal virtual {}

    /**
     * @dev See {IERC165-supportsInterface}.
     */
    function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) {
        return interfaceId == type(IERC5058).interfaceId || super.supportsInterface(interfaceId);
    }
}

セキュリティ

ERC5058によってNFTにロック機能を持たせることは、NFTの貸し出しやステーキングなどを安全に行ううえで非常に有効です。 しかし、NFTのロックが機能を制限する操作であるため、利用にあたっては慎重な設計と運用が求められます。

ロックされたNFTは、ロックが解除されるまでtransferできません。 このため、NFTをロックする権限を他のプロジェクトに与える場合、そのプロジェクトのコントラクトが確実にアンロック機能を備えているかを事前に確認しなければなりません。 もしアンロック機能がなければ、NFTが半永久的に動かせなくなってしまうリスクがあります。

また、利用時にはロック期間を合理的な範囲に設定することが推奨されます。 ERC5058では、NFTをロックする時に「ブロック番号によるロック期限」を指定できます。 これはロック期間が自動的に終了するため、万が一アンロック処理が実行されなかった場合でも、NFTが永遠に使えなくなる事態を防ぐことができます。

プロジェクトがユーザーのNFTをロックする設計を行う場合は、以下の2点を最低限満たしていることが安全な運用の前提となります。

  • アンロック処理を行う機能がコントラクト内に組み込まれていること
  • 自動アンロックが可能なロック期限の設定をサポートしていること

NFTの所有者にとっても、ロック権限の委譲先を慎重に選ぶことが、資産を守るうえで重要になります。

引用

Tyler (@radiocaca), Alex (@gojazdev), John (@sfumato00), "ERC-5058: Lockable Non-Fungible Tokens [DRAFT]," Ethereum Improvement Proposals, no. 5058, April 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5058.

最後に

今回は「NFTにロック機能を適用する仕組みを提案しているERC5058」についてまとめてきました! いかがだったでしょうか?

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

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