はじめに

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

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

今回は、特定のNFTが送付可能か検証する最小限のインターフェースを提案するERC6454についてまとめていきます!

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

「そもそもインターフェースって何?」という方は以下の記事を参考にしてください!

概要

「Minimalistic Transferable interface for Non-Fungible Tokens(NFT)」規格は、ERC721規格を拡張し、NFTがTransfer(送付)可能か識別機能です。

NFTが所有者から送られないようにする機能を導入し、スマートコントラクトやアカウントなどに紐づけて置くことができます。

動機

NFTはEthereumエコシステムで広く使用され、さまざまなユースケースに使用されています。 NFTの送付を止める機能を持つことで、NFTのユーティリティと進化の新たな可能性が生まれます。

ERC721規格のNFTに以下のような新しいユーティリティを与えます。

  • 検証可能な帰属情報
  • 不変のプロパティ

検証可能な帰属情報

個人の実績をNFTによって表現する場合があります。 NFTは、慈善活動やスポーツ成績など、さまざまな実績を表すために使用されることもあります。 しかし、これらの実績を示すNFTを他の人に送付できる場合、信頼性と信憑性が問われます。 NFTを特定のアドレスに紐付けることで、NFTを所有するアドレスが実績を保証できます。 これにより、安全で検証可能な個人の実績の記録が作成されます。 帰属を検証することは実績を示すNFTの信用性と価値を確立し、保有者の実績を認識できる資産を作り出せます。

不変のプロパティ

NFTのプロパティはNFTを区別し、その希少性を確立するために役立ちます。 しかし、発行者によるNFTプロパティの中央集権的な管理は、これらのプロパティの唯一無二性を損なう可能性があります。 NFTを特定のプロパティに結びつけることで、オリジナルNFTがプロパティを保持して唯一無二性を保てます。

送付不可能なNFTをスキルや能力を表すものとして使用するブロックチェーンゲームでは、各スキルは特定のプレイヤーやNFTに結びつけられたユニークで永久的な資産になります。 これにより、プレイヤーは獲得したスキルの所有権を保持し、他のプレイヤーに売買または交換されるのを防ぐことができます。 また、これらのスキルの価値が高まり、キャラクターのカスタマイズと個性化を可能にすることでプレイヤー体験が向上します。

仕様

関数

javascript
pragma solidity ^0.8.16;

interface IERC6454 /* is IERC165 */ {
    function isTransferable(uint256 tokenId, address from, address to) external view returns (bool);
}
  • 引数で受け取ったtokenIdのNFTが送付可能かチェックする関数です。
  • 戻り値がfalseの時、送付処理の実行を中止します。
  • Mint処理以外の時、tokenIdが存在しない場合は実行を中止します。
  • 引数のfromのアドレスに送付権限があるかチェックすることもできるが、使用者や開発者からすると想定外の処理になりかねないため推奨はしていないです。

引数

  • tokenId
    • NFTのトークンID。
  • from
    • NFTの送付元アドレス
  • to
    • NFTの送付先アドレス

戻り値

  • 指定されたトークンIDのNFTが送付可能かどうか。

その他の仕様

  • NFTが送付可能かどうかを判断するために、toとfromに0x0000000000000000000000000000000000000000アドレスを渡したときに適切なbool値を返す。
  • NFTが送付可能かどうかは、NFTをMintするとき(fromが0x0000000000000000000000000000000000000000)とNFTをBurnするとき(toが0x0000000000000000000000000000000000000000)に影響しないようにする。
  • NFTが送付不可能な時、MintとBurnを除くNFTのあらゆる種類の送付は実行を中止する。
  • NFTが送付可能かどうかは、存在しないNFTのtokenIdを許可する例外を作るべき。
    • fromは0x0000000000000000000000000000000000000000で、toは0x0000000000000000000000000000000000000000であってはならない。
  • NFTがBurn可能かどうかは、fromが0x0000000000000000000000000000000000000000でtoは0x0000000000000000000000000000000000000000であるべき。
  • 実装者はfromのアドレスがNFTの送付権限を所有しているか検証するか選べるが、関数とやり取りする誰もがそれに依存すべきではない。
    • これはfromがNFTの送付元の所有者ではなく、トランザクションの実行アドレスを検証しています。

補足

この提案を設計する際に以下の質問を考慮しました。

新たな規格を提案するべきか?

既にNFTの標準規格がある中で、新たに規格を提案すべきか?そしてどのように比較されますか?

NFTの実装に必要な最小限の仕様を目指し、既存の提案の中に最小限のインターフェースを提示しているものはないためより効率的な解決策を提供します。

なぜEventがないのか?

なぜこのインターフェースにはEventがないのか?

NFTが送付不可能になるときの条件が様々あるため(ブロック番号など)、イベントを発生させることができない時があるためです。

状態管理機能は?

送付可能/不可能の状態管理機能は提案に含めるべきか?

最小限のインターフェースの提案という目的を維持するために含めないことを決定しました。 これにより、様々なカスタム実装が可能になりました。

EIPの理由は?

なぜこれがEIPであるべきなのか?1つの機能しかないからか?

この提案のコアは、ERC721規格のNFTの送付を防ぐことだけなため、transfer関数をオーバーライドすることによって実現できるという意見もあります。 確かに正しいが、スマートコントラクトの実行前にNFTが送付不可能であることを確認する唯一の方法は、送付可能なインターフェースを持っていることです。 これにより、スマートコントラクトはNFTが送付可能か検証でき、トランザクションが失敗しガス代が無駄にならなくて済む。

引数を減らせるか?

引数をtokenIdのみにできるか?

提案の初期では引数をtokenIdのみする方法もありました。 しかし、NFTが異なる理由で送付不可能になることが、議論を通じて提起されました。 より柔軟な実装を実現するために0x0000000000000000000000000000000000000000アドレスをtoとfromに渡す実装にしました。

フロントエンドについて

フロントエンドの最良のユーザーエクスペリエンスは何ですか?

NFTが送付可能かチェックする方法を持つことです。 また、関数の戻り値をチェックしてNFTが送付可能か判別し、処理を実行するか中止するか判別できるようにするべきです。 この実装がないと、トークンが送付可能かどうかはガスの計算をしてトランザクションがリバートするかどうかをチェックすることしかありません。 これはユーザー体験が悪いので避けるべきです。

送付権限の検証は?

isTransferableがNFTの送付権限の検証を強制すべきですか?

fromがNFT送付の実行者を表すことを考慮しました。 fromがNFTの所有者かNFTの送付権限があるか検証することは有益であるが、オプションにすることにしました。 最小限のインターフェースの提案という目的があり、送付権限がすでに規格化されているため、isTransferableは既に規格化された機能を使用して、指定されたアドレスが送付を実行できるかどうかを検証することができます。 また、送付権限の検証を強制することは、送付可能かの検証に追加の確認が必要となるため、ガス消費量が増加することを意味します。

互換性

ERC721と完全に互換性がある。

テストケース

以下のコマンドで実行できます。

text
cd ../assets/eip-6454
npm install
npx hardhat test
transferable.ts
import { ethers } from "hardhat";
import { expect } from "chai";
import { BigNumber } from "ethers";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { ERC721TransferableMock } from "../typechain-types";

async function transferableTokenFixture(): Promise<ERC721TransferableMock> {
  const factory = await ethers.getContractFactory("ERC721TransferableMock");
  const token = await factory.deploy("Chunky", "CHNK");
  await token.deployed();

  return token;
}

describe("Transferable", async function () {
  let nonTransferable: ERC721TransferableMock;
  let owner: SignerWithAddress;
  let otherOwner: SignerWithAddress;
  const tokenId = 1;

  beforeEach(async function () {
    const signers = await ethers.getSigners();
    owner = signers[0];
    otherOwner = signers[1];
    nonTransferable = await loadFixture(transferableTokenFixture);

    await nonTransferable.mint(owner.address, 1);
    await nonTransferable.mint(otherOwner.address, 2);
  });

  it("can support IRMRKNonTransferable", async function () {
    expect(await nonTransferable.supportsInterface("0x91a6262f")).to.equal(true);
  });

  it("does not support other interfaces", async function () {
    expect(await nonTransferable.supportsInterface("0xffffffff")).to.equal(false);
  });

  it("cannot transfer", async function () {
    expect(
      nonTransferable
        .connect(owner)
        ["safeTransferFrom(address,address,uint256)"](
          owner.address,
          otherOwner.address,
          tokenId + 1
        )
    ).to.be.revertedWithCustomError(nonTransferable, "CannotTransferNonTransferable");
  });

  it("returns the expected transferability state", async function () {
    expect(await nonTransferable['isTransferable(uint256,address,address)'](tokenId, ethers.constants.AddressZero, ethers.constants.AddressZero)).to.equal(false);
    expect(await nonTransferable['isTransferable(uint256,address,address)'](tokenId, ethers.constants.AddressZero, otherOwner.address)).to.equal(true);
  })

  it("reverts if token does not exist", async function () {
    await expect(nonTransferable['isTransferable(uint256,address,address)'](10, owner.address, otherOwner.address)).to.be.revertedWith("ERC721: invalid token ID");
  });

  it("can burn", async function () {
    await nonTransferable.connect(owner).burn(tokenId);
    await expect(nonTransferable.ownerOf(tokenId)).to.be.revertedWith(
      "ERC721: invalid token ID"
    );
  });
});

参考実装

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

pragma solidity ^0.8.16;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "../IERC6454.sol";
import "hardhat/console.sol";

error CannotTransferNonTransferable();

/**
 * @title ERC721TransferableMock
 * Used for tests
 */
contract ERC721TransferableMock is IERC6454, ERC721 {
    address public owner;

    constructor(
        string memory name,
        string memory symbol
    ) ERC721(name, symbol) {
        owner = msg.sender;
    }

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }

    function burn(uint256 tokenId) public {
        _burn(tokenId);
    }

    function isTransferable(uint256 tokenId, address from, address to) public view returns (bool) {
        if (from == address(0x0) && to == address(0x0)){
            return false;
        }
        // Only allow minting and burning
        if (from == address(0x0) || to == address(0x0)){
            return true;
        }
        _requireMinted(tokenId);
        return false;
    }

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256 firstTokenId,
        uint256 batchSize
    ) internal virtual override {
        super._beforeTokenTransfer(from, to, firstTokenId, batchSize);

        uint256 lastTokenId = firstTokenId + batchSize;
        for (uint256 i = firstTokenId; i < lastTokenId; ) {
            if (!isTransferable(i, from, to)) {
                revert CannotTransferNonTransferable();
            }
            unchecked {
                i++;
            }
        }
    }

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

セキュリティ

スマートコントラクトは特定のインターフェースを実装できるが、不正な値を返すことも可能です。 NFTが送付可能な場合にisTransferableでfalseを返すなどです。 このようなコントラクトがあると、他のコントラクトからするとNFTが送付不可能と誤認する可能性があります。 怪しいコントラクトを削除などはできないため、やり取りを避けることを推奨します。

送付可能かは時間と共に変化する可能性があるため、NFTの状態が送付可能か検証することが重要です。 このインターフェースを実装するdAppやマーケットプレイス、ウォレットは、NFTが表示されるたびにNFTの状態を検証するべきです。

最後に

今回は「特定のNFTが送付可能か検証する最小限のインターフェースを提案するERC6454」についてまとめてきました。 いかがだったでしょうか? 実装については今後追記していきます。

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

採用強化中!

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

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