はじめに

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

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

今回は、consumerというロールを付与して、特定のNFTに対して様々なことができる権限を付与するERC4400についてまとめていきます!

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

トークンIDとは、NFTを識別するユニークな値です。「1」や「2」など数字が使われることが多いです。

概要

ERC721規格のNFTの所有者が、特定の役割(以下ロール)を他のアドレスへ与えるための標準化された関数を定義しています。 特定のロールを与えられたアドレスをこの提案の中ではconsumerと名付けています。 特定のトークンIDのNFTのロール情報を取得するとともに、ロールを付与されたアドレスが変更されたときに標準化されたイベントを発行します。 この機能はERC721の拡張機能として、ERC721規格に依存しています。

動機

ほとんどのERC721コントラクトでは、NFTに関連する権限を付与する独自のカスタムロールを導入しています。 ロールが付与されたアドレスは、NFTを所有したりNFTに対して何らかのアクションを実行できるようになります。 例えば、メタバースにおいてLandのNFTにoperatorやcontributorなどのロールを用意し、土地の所有者が他のアドレスにLand内の建築などをお願いすることができます。

NFTが所有権以外のユーティリティを持つことは一般的です。 これを少し難しい言葉で言い換えると、ユーザーインターフェースとコントラクトを管理し、コントラクトと互換性を持つためには、標準化されたロールが別途必要になります。

consumerというロールを持つことで、プロトコルはERC721規格のNFTを発行するdApps上に統合して構築されます。 例として、汎用的なNFTレンタルマーケットが挙げられます。

この提案から利益を得ることができるコントラクトやアプリケーションの例としては、以下が挙げられます。

  • メタバース内に存在する土地やその他の種類のデジタル資産(土地上での建築、土地/キャラクター/服/イベントのパスのレンタルなど)。
  • NFTベースのイールドファーミング。NFTを所有する「ステイカー」は、NFTをステーキングコントラクトに譲渡した後でも所有している状態と同じ利益を得ることができます。

仕様

javascript
interface IEIP721Consumable {
    event ConsumerChanged(address indexed owner, address indexed consumer, uint256 indexed tokenId);

    function consumerOf(uint256 _tokenId) view external returns (address);

    function changeConsumer(address _consumer, uint256 _tokenId) external;
}

ConsumerChanged

ERC721規格のNFTの所有者がconsumerロールを変更した時に発行されるイベント。 consumerに0アドレスが指定されている場合、consumerアドレスは存在しないことを示します。 また、Transferイベントが発行され、該当するNFTが存在する場合にconsumerアドレスがなしになったことを伝えます。

consumerOf

あるNFTのconsumerアドレスを取得する関数。 引数の_tokenIdに一致するNFTのconsumerアドレスが0アドレスの時、consumerロールを付与されたアドレスは存在しないことを意味します。 また、_tokenIdに一致するNFTが存在しない場合はエラーを投げます。

changeConsumer

あるNFTのconsumerアドレスの対象を変更、確認する関数。 引数の_tokenIdに一致するNFTのconsumerアドレスが0アドレスの時、consumerロールを付与されたアドレスは存在しないことを意味します。 また、この関数の実行アドレスが、NFTの所有者・所有者から権限やロールを付与されたアドレスでない場合はエラーを投げます。

仕様の補足

EIP721Consumable拡張を実装するすべてのコントラクトは、consumerの権限を自由に定義できます。 システム内でconsumerを付与されたアドレスに何を許可されているか。 また、consumerを付与されたアドレスは、ERC721仕様に基づいたNFTの所有者や所有者から権限を付与されたアドレスとはみなされません。 したがって、NFTの送付や別のアドレスへ権限を付与することはできません。

NFTが送付された時、consumerを付与されたアドレスはデフォルトのアドレスに変更されなければなりません。 デフォルトのアドレスにはaddress(0)を使用することを推奨しています。

supportsInterface関数は、0x953c8dfaと呼ばれたときにtrueを返さなければなりません。

補足

実装に影響を与えないように以下に注意をする必要があります。

  • コントラクトの肥大化を防ぐために、インターフェースの関数の数を最小限に保つ。
  • シンプルにする。
  • ガス効率に意識を向ける。
  • 既存の権限(所有者、オペレーター、承認済みアドレスなど)の再利用やオーバーロードをしない。

名前

目的と一致しているため、consumerという名前をつけました。 必ずしも所有権を持つわけではないが、トークンを利用するということがわかります。 他の候補として、operatorもありましたが、既にERC721内で定義され使用されています。

制限

所有者の権限を持つべきでないNFTのための独自の役割が必要なユースケースが多数あります。消費者の役割を実装し、消費者に所有者の権限を付与する契約は、この標準を無意味にします。

後方互換性 consumerには、提案されている標準を無意味にするため、NFTの権限を付与するべきではありません。

テストケース

以下を使用してテストを実行できます。

javascript
import {ethers} from "hardhat";
import {expect} from 'chai';
import {SignerWithAddress} from "@nomiclabs/hardhat-ethers/signers";
import {Erc721Consumable} from "../typechain";

describe("ERC721Consumable", async () => {
	let owner: SignerWithAddress, approved: SignerWithAddress, operator: SignerWithAddress, consumer: SignerWithAddress,
		other: SignerWithAddress;
	let token: Erc721Consumable;
	let snapshotId: any;
	const tokenID = 1; // The first minted NFT

	before(async () => {
		const signers = await ethers.getSigners();
		owner = signers[0];
		approved = signers[1];
		operator = signers[2];
		consumer = signers[3];
		other = signers[4];

		const ConsumableToken = await ethers.getContractFactory("ExampleToken");
		const deployedContract = await ConsumableToken.deploy();
		await deployedContract.deployed();
		token = deployedContract as Erc721Consumable;

		await token.mint();
	})

	beforeEach(async function () {
		snapshotId = await ethers.provider.send('evm_snapshot', []);
	});

	afterEach(async function () {
		await ethers.provider.send('evm_revert', [snapshotId]);
	});

	it('should implement ERC165', async () => {
		expect(await token.supportsInterface("0x953c8dfa")).to.be.true;
	})

	it('should successfully change consumer', async () => {
		// when:
		await token.changeConsumer(consumer.address, tokenID);
		// then:
		expect(await token.consumerOf(tokenID)).to.equal(consumer.address);
	});

	it('should emit event with args', async () => {
		// when:
		const tx = await token.changeConsumer(consumer.address, tokenID);

		// then:
		await expect(tx)
			.to.emit(token, 'ConsumerChanged')
			.withArgs(owner.address, consumer.address, tokenID);
	});

	it('should successfully change consumer when caller is approved', async () => {
		// given:
		await token.approve(approved.address, tokenID);
		// when:
		const tx = await token.connect(approved).changeConsumer(consumer.address, tokenID);

		// then:
		await expect(tx)
			.to.emit(token, 'ConsumerChanged')
			.withArgs(owner.address, consumer.address, tokenID);
		// and:
		expect(await token.consumerOf(tokenID)).to.equal(consumer.address);
	});

	it('should successfully change consumer when caller is operator', async () => {
		// given:
		await token.setApprovalForAll(operator.address, true);
		// when:
		const tx = await token.connect(operator).changeConsumer(consumer.address, tokenID);

		// then:
		await expect(tx)
			.to.emit(token, 'ConsumerChanged')
			.withArgs(owner.address, consumer.address, tokenID);
		// and:
		expect(await token.consumerOf(tokenID)).to.equal(consumer.address);
	});

	it('should revert when caller is not owner, not approved', async () => {
		const expectedRevertMessage = 'ERC721Consumable: changeConsumer caller is not owner nor approved';
		await expect(token.connect(other).changeConsumer(consumer.address, tokenID))
			.to.be.revertedWith(expectedRevertMessage);
	});

	it('should revert when caller is approved for the token', async () => {
		// given:
		await token.changeConsumer(consumer.address, tokenID);
		// then:
		const expectedRevertMessage = 'ERC721Consumable: changeConsumer caller is not owner nor approved';
		await expect(token.connect(consumer).changeConsumer(consumer.address, tokenID))
			.to.be.revertedWith(expectedRevertMessage);
	});

	it('should revert when tokenID is nonexistent', async () => {
		const invalidTokenID = 2;
		const expectedRevertMessage = 'ERC721: owner query for nonexistent token';
		await expect(token.changeConsumer(consumer.address, invalidTokenID))
			.to.be.revertedWith(expectedRevertMessage);
	});

	it('should revert when calling consumerOf with nonexistent tokenID', async () => {
		const invalidTokenID = 2;
		const expectedRevertMessage = 'ERC721Consumable: consumer query for nonexistent token';
		await expect(token.consumerOf(invalidTokenID))
			.to.be.revertedWith(expectedRevertMessage);
	});

	it('should clear consumer on transfer', async () => {
		await token.changeConsumer(consumer.address, tokenID);
		await expect(token.transferFrom(owner.address, other.address, tokenID))
			.to.emit(token, 'ConsumerChanged')
				.withArgs(owner.address, ethers.constants.AddressZero, tokenID);
	})

	it('should emit ConsumerChanged on mint', async () => {
		await expect(token.mint())
			.to.emit(token, 'ConsumerChanged')
				.withArgs(ethers.constants.AddressZero, ethers.constants.AddressZero, tokenID + 1);
	})

	it('should not be able to transfer from consumer', async () => {
		const expectedRevertMessage = 'ERC721: transfer caller is not owner nor approved';
		await token.changeConsumer(consumer.address, tokenID);
		await expect(token.connect(consumer).transferFrom(owner.address, other.address, tokenID))
			.to.revertedWith(expectedRevertMessage)
	})
});

参考実装

javascript
// SPDX-License-Identifier: CC0-1.0
pragma solidity 0.8.11;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "./IERC721Consumable.sol";

contract ERC721Consumable is IERC721Consumable, ERC721 {

    // Mapping from token ID to consumer address
    mapping(uint256 => address) _tokenConsumers;

    constructor (string memory name_, string memory symbol_) ERC721(name_, symbol_) {}

    /**
     * @dev Returns true if the `msg.sender` is approved, owner or consumer of the `tokenId`
     */
    function _isApprovedOwnerOrConsumer(uint256 tokenId) internal view returns (bool) {
        return _isApprovedOrOwner(msg.sender, tokenId) || _tokenConsumers[tokenId] == msg.sender;
    }

    /**
     * @dev See {IERC721Consumable-consumerOf}
     */
    function consumerOf(uint256 _tokenId) view external returns (address) {
        require(_exists(_tokenId), "ERC721Consumable: consumer query for nonexistent token");
        return _tokenConsumers[_tokenId];
    }

    /**
     * @dev See {IERC721Consumable-changeConsumer}
     */
    function changeConsumer(address _consumer, uint256 _tokenId) external {
        address owner = this.ownerOf(_tokenId);
        require(msg.sender == owner || msg.sender == getApproved(_tokenId) || isApprovedForAll(owner, msg.sender),
            "ERC721Consumable: changeConsumer caller is not owner nor approved");
        _changeConsumer(owner, _consumer, _tokenId);
    }

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

    function _beforeTokenTransfer(address _from, address _to, uint256 _tokenId) internal virtual override (ERC721) {
        super._beforeTokenTransfer(_from, _to, _tokenId);
        _changeConsumer(_from, address(0), _tokenId);
    }

    /**
     * @dev Changes the consumer
     * Requirement: `tokenId` must exist
     */
    function _changeConsumer(address _owner, address _consumer, uint256 _tokenId) internal {
        _tokenConsumers[_tokenId] = _consumer;
        emit ConsumerChanged(_owner, _consumer, _tokenId);
    }
}

セキュリティ

EIP721Consumable標準の実装者は、consumerに与える権限をしっかり考える必要があります。 NFTの送付/破棄を許可しないとしても、他の権限を誤って付与してしまうこともあるため、NFTの所有者のみに制限されるように注意する。

最後に

今回は「consumerというロールを付与して、特定のNFTに対して様々なことができる権限を付与するERC4400」についてまとめてきました! いかがだったでしょうか?

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

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

採用強化中!

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

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