ERC4907とは
ERC4907は、NFT(ERC721)にレンタル機能を追加する拡張規格です。2022年6月にEthereum初の「Final」ステータスのERCとして承認されました。通常のERC721では「所有者(owner)」しか存在しませんが、ERC4907では「所有者」とは別に「使用者(user)」という役割を定義できます。
この仕組みにより、NFTの所有権を移転せずに、一時的な使用権を第三者に付与できます。デジタル文化財の文脈では、所蔵機関が所有権を保持したまま、研究者や展示施設に一時的な使用権を与えるといった運用が可能になります。
ownerとuserの違い
| 項目 | owner(所有者) | user(使用者) |
|---|---|---|
| 設定方法 | mint / transferFrom | setUser |
| 有効期限 | なし(永続) | あり(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);
}
}
実装のポイント
setUser: 所有者またはapprovedアドレスのみが使用者を設定できます。_isAuthorizedはOpenZeppelin v5で導入された認可チェック関数です。userOf:block.timestampとexpiresを比較し、期限切れの場合はaddress(0)を返します。ガス消費なし(view関数)で期限チェックが行われます。_updateのオーバーライド: NFTが転送されたとき、使用者情報を自動的にリセットします。これにより、NFTを売却した後も前の使用者が使い続けられる問題を防ぎます。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
デジタル文化財でのユースケース
ERC4907は、デジタル文化財の管理において以下のような活用が考えられます。
- 企画展への貸出: 所蔵機関がNFTの所有権を保持したまま、展示施設に一時的な表示権を付与する
- 研究目的の利用: 研究者に期間限定の高解像度画像アクセス権を設定する
- 教育利用: 教育機関に学期単位での使用権を貸与する
いずれの場合も、有効期限が切れれば自動的に使用権が失効するため、返却処理が不要です。
まとめ
本章では、ERC4907規格を使ったNFTレンタル機能を実装しました。所有者(owner)とは別に使用者(user)と有効期限(expires)を管理する仕組み、転送時の使用者リセット、supportsInterface によるERC4907対応の宣言を学びました。
次章では、このERC4907対応NFTを活用して、NFTレンタルマーケットプレイスを構築します。