ERC-4907とは

前章では、所有権の転送によるNFTレンタルが深刻な問題を抱えていることを確認しました。本章では、ERC-4907(Rental NFT)規格を使用してこの問題を根本的に解決します。

ERC-4907は、EIP-4907として2022年に標準化されたERC721の拡張規格です。この規格の核心は、所有者(owner)と利用者(user)を分離するという点にあります。

EERRCoCou7w-ws2n4ne1e9err0r7::

利用者は有効期限(expires)が設定され、期限を過ぎると自動的に利用権が消滅します。所有権は常に元の所有者に留まるため、前章で問題となった「借用者がNFTを転送してしまう」リスクがありません。

IERC4907インターフェースの確認

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

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

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

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

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

文化財レンタルコントラクトの実装

ERC-4907を組み込んだ文化財NFTコントラクトを実装します。

// 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";

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

    // ERC4907のユーザー情報
    struct UserInfo {
        address user;     // 利用者アドレス
        uint64 expires;   // 有効期限(UNIX timestamp)
    }

    mapping(uint256 => UserInfo) internal _users;

    // レンタル履歴
    struct RentalRecord {
        address borrower;
        uint64 startTime;
        uint64 endTime;
        string purpose;    // 貸出目的(展示、調査等)
    }

    mapping(uint256 => RentalRecord[]) public rentalHistory;

    event UpdateUser(
        uint256 indexed tokenId,
        address indexed user,
        uint64 expires
    );

    event RentalRecorded(
        uint256 indexed tokenId,
        address indexed borrower,
        string purpose,
        uint64 startTime,
        uint64 endTime
    );

    constructor()
        ERC721("CulturalHeritageRentalV2", "CHR2")
        Ownable(msg.sender)
    {}

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

    /// @notice 利用者を設定する(ERC4907)
    /// @dev 所有者または承認されたアドレスのみ呼び出し可能
    function setUser(
        uint256 tokenId,
        address user,
        uint64 expires
    ) external {
        require(
            _isAuthorized(ownerOf(tokenId), msg.sender, tokenId),
            "Not owner or approved"
        );

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

    /// @notice 文化財を貸し出す(目的を記録)
    function lendCulturalItem(
        uint256 tokenId,
        address borrower,
        uint64 durationDays,
        string memory purpose
    ) external {
        require(ownerOf(tokenId) == msg.sender, "Not the owner");

        uint64 startTime = uint64(block.timestamp);
        uint64 endTime = startTime + (durationDays * 1 days);

        // ERC4907でユーザーを設定
        _users[tokenId] = UserInfo(borrower, endTime);
        emit UpdateUser(tokenId, borrower, endTime);

        // レンタル履歴を記録
        rentalHistory[tokenId].push(RentalRecord({
            borrower: borrower,
            startTime: startTime,
            endTime: endTime,
            purpose: purpose
        }));

        emit RentalRecorded(
            tokenId,
            borrower,
            purpose,
            startTime,
            endTime
        );
    }

    /// @notice 利用者を取得する(期限切れの場合は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 getRentalHistoryCount(uint256 tokenId)
        external view returns (uint256)
    {
        return rentalHistory[tokenId].length;
    }

    /// @notice ERC165のサポート確認
    function supportsInterface(bytes4 interfaceId)
        public view override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        // ERC4907のinterfaceId = 0xad092b5c
        return
            interfaceId == bytes4(0xad092b5c) ||
            super.supportsInterface(interfaceId);
    }

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

前章の問題点がすべて解決されていることの検証

ERC-4907によるレンタルでは、前章で発見された問題がどのように解決されるかをテストで確認します。

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

describe("CulturalHeritageRentalV2 (ERC-4907)", function () {
  async function deployFixture() {
    const [owner, borrower, thirdParty] = await ethers.getSigners();
    const Contract = await ethers.getContractFactory(
      "CulturalHeritageRentalV2"
    );
    const contract = await Contract.deploy();
    await contract.mint(owner.address, "ipfs://test-metadata");
    return { contract, owner, borrower, thirdParty };
  }

  it("所有権は元の所有者に残る", async function () {
    const { contract, owner, borrower } = await deployFixture();

    await contract.lendCulturalItem(
      0, borrower.address, 30, "特別展への出品"
    );

    // 所有権は変わっていない
    expect(await contract.ownerOf(0)).to.equal(owner.address);
    // 利用者は借用者
    expect(await contract.userOf(0)).to.equal(borrower.address);
  });

  it("借用者はNFTを第三者に転送できない", async function () {
    const { contract, owner, borrower, thirdParty } =
      await deployFixture();

    await contract.lendCulturalItem(
      0, borrower.address, 30, "調査のため"
    );

    // 借用者がtransferFromしようとしてもエラー
    await expect(
      contract.connect(borrower)
        .transferFrom(owner.address, thirdParty.address, 0)
    ).to.be.reverted;
  });

  it("期限切れ後、利用者は自動的にリセットされる", async function () {
    const { contract, owner, borrower } = await deployFixture();

    await contract.lendCulturalItem(
      0, borrower.address, 30, "展示のため"
    );

    // レンタル中は利用者が設定されている
    expect(await contract.userOf(0)).to.equal(borrower.address);

    // 31日後に時間を進める
    await time.increase(31 * 24 * 60 * 60);

    // 期限切れ後は利用者がリセットされる
    expect(await contract.userOf(0)).to.equal(ethers.ZeroAddress);
  });

  it("レンタル履歴が記録される", async function () {
    const { contract, owner, borrower } = await deployFixture();

    await contract.lendCulturalItem(
      0, borrower.address, 30, "国宝展2024への出品"
    );

    const count = await contract.getRentalHistoryCount(0);
    expect(count).to.equal(1);
  });
});

フロントエンドからの利用

import { ethers } from "ethers";

// 文化財の貸出
async function lendItem(
  tokenId: number,
  borrowerAddress: string,
  durationDays: number,
  purpose: string
) {
  const tx = await contract.lendCulturalItem(
    tokenId,
    borrowerAddress,
    durationDays,
    purpose
  );
  const receipt = await tx.wait();
  console.log(`貸出完了: トークン #${tokenId}`);
  console.log(`借用者: ${borrowerAddress}`);
  console.log(`期間: ${durationDays}日`);
  console.log(`目的: ${purpose}`);
}

// 現在の利用者を確認
async function checkCurrentUser(tokenId: number) {
  const user = await contract.userOf(tokenId);
  const expires = await contract.userExpires(tokenId);

  if (user === ethers.ZeroAddress) {
    console.log(`トークン #${tokenId}: 現在貸出中ではありません`);
  } else {
    const expiryDate = new Date(Number(expires) * 1000);
    console.log(`トークン #${tokenId}: 貸出中`);
    console.log(`利用者: ${user}`);
    console.log(`有効期限: ${expiryDate.toLocaleDateString("ja-JP")}`);
  }
}

// 使用例
await lendItem(
  0,
  "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18",
  90,
  "特別展「東アジアの至宝」への出品"
);

await checkCurrentUser(0);

V1とV2の比較

問題V1(所有権転送)V2(ERC-4907)
所有権の保持借用者に移転元の所有者に留まる
第三者への転送借用者が可能不可能
期限切れの処理手動で返却が必要自動的に利用権が消滅
マーケットでの売却借用者が可能不可能
返却の保証なし不要(自動期限切れ)

まとめ

ERC-4907を導入することで、文化財NFTのレンタル機能を安全に実装できました。所有者と利用者の明確な分離、期限付きアクセス権の自動失効により、博物館間の文化財貸借をブロックチェーン上で信頼性高く管理できます。

次章では、フロントエンドからウォレットを接続し、これまで実装してきた機能をブラウザ上で操作できるようにします。

関連記事