はじめに

初めまして。 CryptoGamesというブロックチェーンゲーム企業でエンジニアをしている cardene(かるでね) です! スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

代表的なゲームはクリプトスペルズというブロックチェーンゲームです。

今回は、ERC1155の拡張機能である、「user」というロールを追加してNFTの所有権と使用権を分離する提案であるERC5006についてまとめていきます!

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

ERC1155については以下を参照してください。

動機:

ERC721と同様に、ERC1155トークンには様々なユースケースが想定されます。 例えば、トークンを使用できるアドレスとトークンの所有者のアドレスが異なる場合があります(例:レンタル)。 そのため、「owner」と「user」に対して別々のロールを持たせた設計は効果的であり、ユーザーは所有者が行えるようなアクション(例:NFTの送付)を実行できないようにする必要があります。

ブログERC & EIP (1).png

ERC721については以下の記事を参考にしてください。

ERC1155については以下の記事を参考にしてください。

仕様

javascript
// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.0;

interface IERC5006 {
    struct UserRecord {
        uint256 tokenId;
        address owner;
        uint64 amount;
        address user;
        uint64 expiry;
    }
    
    event CreateUserRecord(
        uint256 recordId,
        uint256 tokenId,
        uint64  amount,
        address owner,
        address user,
        uint64  expiry
    );

    event DeleteUserRecord(uint256 recordId);

    function usableBalanceOf(address account, uint256 tokenId)
        external
        view
        returns (uint256);

    function frozenBalanceOf(address account, uint256 tokenId)
        external
        view
        returns (uint256);

    function userRecordOf(uint256 recordId)
        external
        view
        returns (UserRecord memory);

    function createUserRecord(
        address owner,
        address user,
        uint256 tokenId,
        uint64 amount,
        uint64 expiry
    ) external returns (uint256);

    function deleteUserRecord(uint256 recordId) external;
}

ERC5006では、「user」ロールがトークンの利用を所有者から分離する仕組みを導入しています。 これにより、トークンの所有者は利用者に対して特定の期間だけトークンの利用を許可できるようになります。

ブログERC & EIP (2).png

構造体

UserRecord

ユーザーレコードを表す構造体。 各レコードは以下の情報を含みます。

  • tokenId
    • トークンのIDを表すuint256型の値。
  • owner
    • トークンの所有者のアドレスを表すaddress型の値。
  • amount
    • ユーザーが利用できるトークンの量を表すuint64型の値。
  • user
    • トークンを利用するユーザーのアドレスを表すaddress型の値。
  • expiry
    • トークンの利用が許可される期限を表すuint64型の値(ブロックタイムスタンプと比較されます)。

Event

CreateUserRecord

新たなユーザーレコードが作成されるときに発行されるイベント。 userがownerの所有するtokenIdで指定されたトークンの一部または全体をexpiry(有効期限)まで利用する許可が与えられた際に発行されるイベント。

DeleteUserRecord

レコードIDに対応するユーザーレコードが削除されたときに発行されるイベント。

関数

usableBalanceOf

アカウントが所有する特定のtokenIdで指定されたトークンの利用可能な総数を取得する関数。

frozenBalanceOf

アカウントが所有する特定のtokenIdで指定されたトークンの凍結された総数(使用できない総数)を取得する関数。

userRecordOf

特定のレコードIDに対応するユーザーレコードを取得する関数。

createUserRecord

ownerが所有するtokenIdで指定されたトークンの一部または全体をuserがexpiry(有効期限)まで利用できるように許可する関数。 条件を満たす場合には新たなユーザーレコードが作成されます。

deleteUserRecord

呼び出し元によって特定のレコードIDに対応するユーザーレコードを削除する関数。

補足

クリアな権利割り当て

2つの「owner」および「user」のロールにより、NFT(非代替トークン)の貸し手と借り手が何ができるか、できないかの権利を管理しやすくなります。 例えば、NFTを所有するのは「owner」であり、そのNFTを期間限定で利用する「user」が別に存在する場合、所有者はそのNFTの使用権を「user」に一定期間与えられるが、所有者の権利(例:NFTの販売や送付など)は所有者しか実行できないため制御されます。

簡単なサードパーティの統合

この提案により、サードパーティのプロトコルがNFTの使用権をNFTの発行者またはNFTアプリケーションから許可なく管理できるようになります。 プロジェクトがuserロールを追加すると、他のプロジェクトは新しく追加されたロールを使用して独自のトランザクションを実装することが可能となります。 例えば、この標準を使用するPFP(Profile Picture)NFTは、userがNFTを30日間レンタルできるレンタルプラットフォームと、userが分割払いでNFTの所有権を購入しながらNFTを使用できるプラットフォームの両方に統合できます。 これらすべては元のPFPプロジェクトの許可がなく実行できます。

後方互換性

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

テストケース

以下のコマンドをターミナルで実行することでテストを実行できます。

text
cd ../assets/eip-5006
npm install
npx hardhat test
javascript
import { expect } from "chai";
import { ethers } from "hardhat";
import hre from "hardhat";

describe("Test 1155 User Role", function () {
    let alice, bob, carl;
    let contract;
    let expiry;

    async function checkRecord(rid,tokenId,amount,owner,user,expiry_) {
        let record = await contract.userRecordOf(rid);
            expect(record[0]).equals(tokenId,"tokenId");
            expect(record[1]).equals(owner,"owner");
            expect(record[2]).equals(amount,"amount");
            expect(record[3]).equals(user,"user");
            expect(record[4]).equals(expiry_,"expiry_");
    }

    beforeEach(async function () {
        [alice, bob, carl] = await ethers.getSigners();

        const ERC5006Demo = await ethers.getContractFactory("ERC5006Demo");

        contract = await ERC5006Demo.deploy("", 3);

        expiry = Math.floor(new Date().getTime() / 1000) + 3600;
    });

    

    describe("", function () {
        
        it("InterfaceId should equals 0xc26d96cc", async function () {
            expect(await contract.getInterfaceId()).equals("0xc26d96cc");
        });

        it("Should set user to bob success", async function () {

            await contract.mint(alice.address, 1, 100);

            await contract.createUserRecord(alice.address, bob.address, 1, 10, expiry);

            await checkRecord(1,1,10,alice.address,bob.address,expiry);

            expect(await contract.usableBalanceOf(bob.address, 1)).equals(10);

            expect(await contract.balanceOf(alice.address, 1)).equals(90);

            expect(await contract.frozenBalanceOf(alice.address, 1)).equals(10);

        });

        it("Should set user to bob fail", async function () {

            await contract.mint(alice.address, 1, 100);

            await contract.createUserRecord(alice.address, bob.address, 1, 10, expiry);
            await contract.createUserRecord(alice.address, bob.address, 1, 10, expiry);
            await contract.createUserRecord(alice.address, bob.address, 1, 10, expiry);
            await expect(contract.createUserRecord(alice.address, bob.address, 1, 10, expiry)).to.be.revertedWith("user cannot have more records");

        });

        it("Should set user to bob fail : balance is not enough", async function () {

            await contract.mint(alice.address, 1, 100);

            await expect(contract.createUserRecord(alice.address, bob.address, 1, 101, expiry)).to.be.revertedWith('ERC1155: insufficient balance for transfer');

        });

        it("Should set user to bob fail : only owner or approved", async function () {

            await contract.mint(alice.address, 1, 100);
            await contract.mint(carl.address, 1, 100);

            await expect(contract.createUserRecord(carl.address, bob.address, 1, 110, expiry)).to.be.revertedWith('only owner or approved');

        });

        it("Should deleteUserRecord success", async function () {

            await contract.mint(alice.address, 1, 100);

            await contract.createUserRecord(alice.address, bob.address, 1, 10, expiry);

            // await hre.network.provider.send("hardhat_mine", ["0x5a0", "0x3c"]);

            await contract.deleteUserRecord(1);

            await checkRecord(1,0,0,"0x0000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000",0);

            expect(await contract.usableBalanceOf(bob.address, 1)).equals(0);

            expect(await contract.balanceOf(alice.address, 1)).equals(100);

            expect(await contract.frozenBalanceOf(alice.address, 1)).equals(0);

        });


        it("bob should deleteUserRecord fail", async function () {

            await contract.mint(alice.address, 1, 100);

            await contract.createUserRecord(alice.address, bob.address, 1, 10, expiry);

            await expect(contract.connect(bob).deleteUserRecord(1)).to.be.revertedWith("only owner or approved");

        });


    });


});

参考実装

javascript
// SPDX-License-Identifier: CC0-1.0

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Receiver.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
import "./IERC5006.sol";

contract ERC5006 is ERC1155, ERC1155Receiver, IERC5006 {
    using EnumerableSet for EnumerableSet.UintSet;
    mapping(uint256 => mapping(address => uint256)) private _frozens;
    mapping(uint256 => UserRecord) private _records;
    mapping(uint256 => mapping(address => EnumerableSet.UintSet))
        private _userRecordIds;
    uint256 _curRecordId;
    uint256 recordLimit;

    constructor(string memory uri_, uint256 recordLimit_) ERC1155(uri_) {
        recordLimit = recordLimit_;
    }

    function isOwnerOrApproved(address owner) public view returns (bool) {
        require(
            owner == msg.sender || isApprovedForAll(owner, msg.sender),
            "only owner or approved"
        );
        return true;
    }

    function usableBalanceOf(address account, uint256 tokenId)
        public
        view
        override
        returns (uint256 amount)
    {
        uint256[] memory recordIds = _userRecordIds[tokenId][account].values();
        for (uint256 i = 0; i < recordIds.length; i++) {
            if (block.timestamp <= _records[recordIds[i]].expiry) {
                amount += _records[recordIds[i]].amount;
            }
        }
    }

    function frozenBalanceOf(address account, uint256 tokenId)
        public
        view
        override
        returns (uint256)
    {
        return _frozens[tokenId][account];
    }

    function userRecordOf(uint256 recordId)
        public
        view
        override
        returns (UserRecord memory)
    {
        return _records[recordId];
    }

    function createUserRecord(
        address owner,
        address user,
        uint256 tokenId,
        uint64 amount,
        uint64 expiry
    ) public override returns (uint256) {
        require(isOwnerOrApproved(owner));
        require(user != address(0), "user cannot be the zero address");
        require(amount > 0, "amount must be greater than 0");
        require(expiry > block.timestamp, "expiry must after the block timestamp");
        require(
            _userRecordIds[tokenId][user].length() < recordLimit,
            "user cannot have more records"
        );
        _safeTransferFrom(owner, address(this), tokenId, amount, "");
        _frozens[tokenId][owner] += amount;
        _curRecordId++;
        _records[_curRecordId] = UserRecord(
            tokenId,
            owner,
            amount,
            user,
            expiry
        );
        _userRecordIds[tokenId][user].add(_curRecordId);
        emit CreateUserRecord(
            _curRecordId,
            tokenId,
            amount,
            owner,
            user,
            expiry
        );
        return _curRecordId;
    }

    function deleteUserRecord(uint256 recordId) public override {
        UserRecord storage _record = _records[recordId];
        require(isOwnerOrApproved(_record.owner));
        _safeTransferFrom(
            address(this),
            _record.owner,
            _record.tokenId,
            _record.amount,
            ""
        );
        _frozens[_record.tokenId][_record.owner] -= _record.amount;
        _userRecordIds[_record.tokenId][_record.user].remove(recordId);
        delete _records[recordId];
        emit DeleteUserRecord(recordId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC1155, ERC1155Receiver)
        returns (bool)
    {
        return
            interfaceId == type(IERC5006).interfaceId ||
            ERC1155.supportsInterface(interfaceId) ||
            ERC1155Receiver.supportsInterface(interfaceId);
    }

    function onERC1155Received(
        address operator,
        address from,
        uint256 tokenId,
        uint256 value,
        bytes calldata data
    ) external pure override returns (bytes4) {
        return IERC1155Receiver.onERC1155Received.selector;
    }

    function onERC1155BatchReceived(
        address operator,
        address from,
        uint256[] calldata ids,
        uint256[] calldata values,
        bytes calldata data
    ) external pure override returns (bytes4) {
        return IERC1155Receiver.onERC1155BatchReceived.selector;
    }
}

構造体

UserRecord

`user`レコードを表す構造体。 トークンの利用者に関する情報を格納します。 トークンのID、所有者のアドレス、利用可能な数量、利用者のアドレス、有効期限を含みます。

変数

_frozens

トークンのIDごとにアカウントごとの凍結されたトークン数量を追跡するためのマッピング配列。 利用者がトークンを一時的に凍結(ロック)することができます。

_records

ユーザーレコードをトークンのIDごとに追跡するためのマッピング配列。 userレコードはUserRecord構造体で定義された情報を保持します。

_userRecordIds

利用者のアドレスごとに、所有しているトークンIDに関連するuserレコードのIDを追跡するためのマッピング配列。 利用者が複数のトークンを利用することができるため、IDのセットが管理されます。

_curRecordId

現在のユーザーレコードIDを追跡するための変数。

recordLimit

1人の利用者が持つことができるユーザーレコードの最大数を設定した変数。

コンストラクタ

コントラクトのデプロイ時に実行される初期化関数。 URI(Uniform Resource Identifier)とuserレコードの最大数制限を引数に受け取ります。

関数

createUserRecord

利用者が所有者のトークンを一部または全体を利用できるように許可するための関数。 条件を満たす場合には新たなユーザーレコードが作成されます。

deleteUserRecord

利用者が作成したユーザーレコードを削除するための関数。

usableBalanceOf

アカウントが所有する特定のトークンの利用可能な総数返す関数。 ユーザーレコードの有効期限内のトークン量を計算して返します。

frozenBalanceOf

アカウントが所有する特定のトークンの凍結された総数(ロックされたトークン)を返す関数。

supportsInterface

ERC1155のインターフェースに加えて、ERC5006とERC1155Receiverのインターフェースをサポートする関数です。

**ERC165については以下の記事を参照してください。

セキュリティ

この規格では、所有者の権利を保護することができます。 所有者はNFTに紐づいたuserを変更できますが、userはNFTを送付できません。

考察

NFTの所有権と使用権を分離するというアプローチは面白いと考えています。 それこそ「NFTのレンタル」なども簡単に実現できそうです。 ERC1155の場合は同じトークンIDのNFTが何個も存在するため、ゲームアイテムなどで利用がイメージできます。 ゲーム内で使用されるアイテムを一定期間他のユーザーにレンタルしたり、各ユーザーに毎月有効期限つきで使用できるアイテムを配布するなど活用できる場面は多そうです。 この規格を使用して何か作ってみても面白そうですね。

最後に

今回は「ERC1155の拡張機能である、「user」というロールを追加してNFTの所有権と使用権を分離する提案であるERC5006」についてまとめてきました! いかがだったでしょうか?

実装については今後追記していきます。

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

採用強化中!

CryptoGamesでは一緒に働く仲間を大募集中です。

この記事で書いた自分の経験からもわかるように、裁量権を持って働くことができて一気に成長できる環境です。 「ブロックチェーンやWeb3、NFTに興味がある」、「スマートコントラクトの開発に携わりたい」など、少しでも興味を持っている方はまずはお話ししましょう!