ERC4907とは

ERC4907は、NFT(ERC721)にレンタル機能を追加する拡張規格です。2022年6月にEthereum初の「Final」ステータスのERCとして承認されました。通常のERC721では「所有者(owner)」しか存在しませんが、ERC4907では「所有者」とは別に「使用者(user)」という役割を定義できます。

この仕組みにより、NFTの所有権を移転せずに、一時的な使用権を第三者に付与できます。デジタル文化財の文脈では、所蔵機関が所有権を保持したまま、研究者や展示施設に一時的な使用権を与えるといった運用が可能になります。

ownerとuserの違い

項目owner(所有者)user(使用者)
設定方法mint / transferFromsetUser
有効期限なし(永続)あり(expires)
転送権限ありなし
使用権ありあり(期限内)

IERC4907インターフェース

ERC4907は以下のインターフェースを定義しています。

// contracts/interfaces/IERC4907.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC4907 {
    /// @notice user が変更されたときに発行されるイベント
    event UpdateUser(
        uint256 indexed tokenId,
        address indexed user,
        uint64 expires
    );

    /// @notice NFTの使用者と有効期限を設定する
    /// @param tokenId 対象のトークンID
    /// @param user 使用者のアドレス
    /// @param expires 有効期限(UNIXタイムスタンプ)
    function setUser(
        uint256 tokenId,
        address user,
        uint64 expires
    ) external;

    /// @notice NFTの使用者を取得する
    /// @param tokenId 対象のトークンID
    /// @return user 使用者のアドレス(期限切れの場合は address(0))
    function userOf(uint256 tokenId) external view returns (address);

    /// @notice 使用権の有効期限を取得する
    /// @param tokenId 対象のトークンID
    /// @return expires 有効期限のUNIXタイムスタンプ
    function userExpires(uint256 tokenId) external view returns (uint256);
}

ポイントは userOf 関数です。有効期限が切れている場合は自動的に address(0) を返すため、期限切れの使用権を手動で取り消す必要がありません。

ERC4907コントラクトの実装

CulturalNFTを拡張して、ERC4907対応のレンタル可能NFTを実装します。

// contracts/CulturalRentableNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

// ERC4907のユーザー情報
struct UserInfo {
    address user;    // 使用者のアドレス
    uint64 expires;  // 有効期限(UNIXタイムスタンプ)
}

contract CulturalRentableNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;

    // tokenId => UserInfo
    mapping(uint256 => UserInfo) private _users;

    /// @notice user が変更されたときに発行されるイベント
    event UpdateUser(
        uint256 indexed tokenId,
        address indexed user,
        uint64 expires
    );

    /// @notice ミント時のイベント
    event NFTMinted(
        uint256 indexed tokenId,
        address indexed to,
        string tokenURI
    );

    constructor()
        ERC721("Cultural Rentable NFT", "CRNFT")
        Ownable(msg.sender)
    {}

    /// @notice NFTをミントする
    function mint(
        address to,
        string memory uri
    ) public onlyOwner returns (uint256) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
        emit NFTMinted(tokenId, to, uri);
        return tokenId;
    }

    /// @notice NFTの使用者と有効期限を設定する
    /// @dev ownerまたはapprovedアドレスのみ呼び出せる
    function setUser(
        uint256 tokenId,
        address user,
        uint64 expires
    ) public {
        require(
            _isAuthorized(ownerOf(tokenId), msg.sender, tokenId),
            "ERC4907: caller is not owner nor approved"
        );

        _users[tokenId] = UserInfo(user, expires);
        emit UpdateUser(tokenId, user, expires);
    }

    /// @notice NFTの現在の使用者を取得する
    /// @return 使用者のアドレス(期限切れの場合は address(0))
    function userOf(uint256 tokenId) public view returns (address) {
        if (uint256(_users[tokenId].expires) >= block.timestamp) {
            return _users[tokenId].user;
        }
        return address(0);
    }

    /// @notice 使用権の有効期限を取得する
    function userExpires(
        uint256 tokenId
    ) public view returns (uint256) {
        return _users[tokenId].expires;
    }

    /// @notice トークン転送時に使用者情報をリセットする
    function _update(
        address to,
        uint256 tokenId,
        address auth
    ) internal override returns (address) {
        address from = super._update(to, tokenId, auth);

        // 転送時に使用者をリセット
        if (from != to && _users[tokenId].user != address(0)) {
            delete _users[tokenId];
            emit UpdateUser(tokenId, address(0), 0);
        }

        return from;
    }

    /// @notice ERC165: サポートするインターフェースの確認
    function supportsInterface(
        bytes4 interfaceId
    )
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        // ERC4907のインターフェースID
        return
            interfaceId == 0xad092b5c ||
            super.supportsInterface(interfaceId);
    }

    function tokenURI(
        uint256 tokenId
    )
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }
}

実装のポイント

  1. setUser: 所有者またはapprovedアドレスのみが使用者を設定できます。_isAuthorized はOpenZeppelin v5で導入された認可チェック関数です。

  2. userOf: block.timestampexpires を比較し、期限切れの場合は address(0) を返します。ガス消費なし(view関数)で期限チェックが行われます。

  3. _updateのオーバーライド: NFTが転送されたとき、使用者情報を自動的にリセットします。これにより、NFTを売却した後も前の使用者が使い続けられる問題を防ぎます。

  4. supportsInterface: 0xad092b5c はERC4907のインターフェースIDです。マーケットプレイスなどがERC4907対応かどうかを判定するために使用されます。

テスト

// test/CulturalRentableNFT.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";

describe("CulturalRentableNFT", function () {
  async function deployFixture() {
    const [owner, user1, user2] = await ethers.getSigners();
    const NFT = await ethers.getContractFactory("CulturalRentableNFT");
    const nft = await NFT.deploy();
    return { nft, owner, user1, user2 };
  }

  describe("基本的なNFT機能", function () {
    it("NFTをミントできる", async function () {
      const { nft, owner } = await deployFixture();

      await nft.mint(owner.address, "ipfs://test-metadata");
      expect(await nft.ownerOf(0)).to.equal(owner.address);
      expect(await nft.tokenURI(0)).to.equal("ipfs://test-metadata");
    });
  });

  describe("ERC4907: レンタル機能", function () {
    it("使用者を設定できる", async function () {
      const { nft, owner, user1 } = await deployFixture();

      await nft.mint(owner.address, "ipfs://test");

      // 1時間後に期限切れ
      const expiry = (await time.latest()) + 3600;
      await nft.setUser(0, user1.address, expiry);

      expect(await nft.userOf(0)).to.equal(user1.address);
      expect(await nft.userExpires(0)).to.equal(expiry);
    });

    it("UpdateUserイベントが発行される", async function () {
      const { nft, owner, user1 } = await deployFixture();

      await nft.mint(owner.address, "ipfs://test");
      const expiry = (await time.latest()) + 3600;

      await expect(nft.setUser(0, user1.address, expiry))
        .to.emit(nft, "UpdateUser")
        .withArgs(0, user1.address, expiry);
    });

    it("期限切れ後はuserOfがaddress(0)を返す", async function () {
      const { nft, owner, user1 } = await deployFixture();

      await nft.mint(owner.address, "ipfs://test");

      // 1時間後に期限切れ
      const expiry = (await time.latest()) + 3600;
      await nft.setUser(0, user1.address, expiry);

      // 期限内: 使用者が返る
      expect(await nft.userOf(0)).to.equal(user1.address);

      // 時間を2時間進める
      await time.increase(7200);

      // 期限切れ: address(0)が返る
      expect(await nft.userOf(0)).to.equal(ethers.ZeroAddress);
    });

    it("所有者以外はsetUserできない", async function () {
      const { nft, owner, user1, user2 } = await deployFixture();

      await nft.mint(owner.address, "ipfs://test");
      const expiry = (await time.latest()) + 3600;

      await expect(
        nft.connect(user1).setUser(0, user2.address, expiry)
      ).to.be.revertedWith(
        "ERC4907: caller is not owner nor approved"
      );
    });

    it("NFT転送時に使用者がリセットされる", async function () {
      const { nft, owner, user1, user2 } = await deployFixture();

      await nft.mint(owner.address, "ipfs://test");

      // 使用者を設定
      const expiry = (await time.latest()) + 3600;
      await nft.setUser(0, user1.address, expiry);
      expect(await nft.userOf(0)).to.equal(user1.address);

      // NFTを転送
      await nft.transferFrom(owner.address, user2.address, 0);

      // 使用者がリセットされている
      expect(await nft.userOf(0)).to.equal(ethers.ZeroAddress);
    });

    it("ERC4907インターフェースをサポートする", async function () {
      const { nft } = await deployFixture();

      // ERC4907のインターフェースID
      expect(await nft.supportsInterface("0xad092b5c")).to.be.true;

      // ERC721のインターフェースID
      expect(await nft.supportsInterface("0x80ac58cd")).to.be.true;
    });
  });
});

テストでは、Hardhat Network Helpersの time ユーティリティを使って、ブロックチェーンの時刻を操作しています。time.latest() で現在のブロック時刻を取得し、time.increase() で時間を進めることで、有効期限の挙動を検証できます。

npx hardhat test test/CulturalRentableNFT.test.ts
C7ulEptRauCsr4saNN9使UNEilFF0pFRnRTT7dTCge:a4nt9te0aUus7bsse使leeterrUNOsFfeTraddress(0)

デジタル文化財でのユースケース

ERC4907は、デジタル文化財の管理において以下のような活用が考えられます。

  • 企画展への貸出: 所蔵機関がNFTの所有権を保持したまま、展示施設に一時的な表示権を付与する
  • 研究目的の利用: 研究者に期間限定の高解像度画像アクセス権を設定する
  • 教育利用: 教育機関に学期単位での使用権を貸与する

いずれの場合も、有効期限が切れれば自動的に使用権が失効するため、返却処理が不要です。

まとめ

本章では、ERC4907規格を使ったNFTレンタル機能を実装しました。所有者(owner)とは別に使用者(user)と有効期限(expires)を管理する仕組み、転送時の使用者リセット、supportsInterface によるERC4907対応の宣言を学びました。

次章では、このERC4907対応NFTを活用して、NFTレンタルマーケットプレイスを構築します。

関連記事