マーケットプレイスの概要
前章でERC4907対応のレンタル可能NFTを実装しました。本章では、そのNFTを出品・レンタルできるマーケットプレイスコントラクトを構築します。NFTの所有者がレンタル条件(価格・期間)を設定して出品し、借り手がETHを支払ってレンタルするという仕組みです。
マーケットプレイスの機能
マーケットプレイスコントラクト
// contracts/RentalMarketplace.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @notice ERC4907の最低限のインターフェース
interface IERC4907 {
function setUser(
uint256 tokenId,
address user,
uint64 expires
) external;
function userOf(uint256 tokenId) external view returns (address);
function userExpires(uint256 tokenId) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
function getApproved(uint256 tokenId)
external view returns (address);
function isApprovedForAll(address owner, address operator)
external view returns (bool);
}
contract RentalMarketplace is ReentrancyGuard, Ownable {
/// @notice 出品情報の構造体
struct Listing {
address nftContract; // NFTコントラクトのアドレス
uint256 tokenId; // トークンID
address lender; // 貸し手(NFT所有者)
uint256 pricePerDay; // 1日あたりのレンタル料(wei)
uint256 maxDuration; // 最大レンタル期間(秒)
uint256 minDuration; // 最小レンタル期間(秒)
bool isActive; // 出品中かどうか
}
// listingId => Listing
mapping(uint256 => Listing) public listings;
uint256 public nextListingId;
// プラットフォーム手数料(パーセント、例: 250 = 2.5%)
uint256 public platformFeeBps;
uint256 public constant MAX_FEE_BPS = 1000; // 最大10%
// イベント
event Listed(
uint256 indexed listingId,
address indexed nftContract,
uint256 indexed tokenId,
address lender,
uint256 pricePerDay,
uint256 maxDuration
);
event Rented(
uint256 indexed listingId,
address indexed renter,
uint64 expires,
uint256 totalPrice
);
event Delisted(uint256 indexed listingId);
constructor(uint256 _platformFeeBps) Ownable(msg.sender) {
require(_platformFeeBps <= MAX_FEE_BPS, "Fee too high");
platformFeeBps = _platformFeeBps;
}
/// @notice NFTをレンタル出品する
/// @param nftContract ERC4907対応NFTのコントラクトアドレス
/// @param tokenId トークンID
/// @param pricePerDay 1日あたりのレンタル料(wei)
/// @param maxDuration 最大レンタル期間(秒)
/// @param minDuration 最小レンタル期間(秒)
function listForRent(
address nftContract,
uint256 tokenId,
uint256 pricePerDay,
uint256 maxDuration,
uint256 minDuration
) external returns (uint256) {
IERC4907 nft = IERC4907(nftContract);
// NFTの所有者であることを確認
require(
nft.ownerOf(tokenId) == msg.sender,
"Not the NFT owner"
);
// マーケットプレイスがsetUserを呼べるようにapproveされていること
require(
nft.getApproved(tokenId) == address(this) ||
nft.isApprovedForAll(msg.sender, address(this)),
"Marketplace not approved"
);
require(pricePerDay > 0, "Price must be > 0");
require(maxDuration >= minDuration, "Invalid duration range");
require(minDuration >= 1 hours, "Min duration too short");
uint256 listingId = nextListingId++;
listings[listingId] = Listing({
nftContract: nftContract,
tokenId: tokenId,
lender: msg.sender,
pricePerDay: pricePerDay,
maxDuration: maxDuration,
minDuration: minDuration,
isActive: true
});
emit Listed(
listingId,
nftContract,
tokenId,
msg.sender,
pricePerDay,
maxDuration
);
return listingId;
}
/// @notice NFTをレンタルする
/// @param listingId 出品ID
/// @param duration レンタル期間(秒)
function rentNFT(
uint256 listingId,
uint256 duration
) external payable nonReentrant {
Listing storage listing = listings[listingId];
require(listing.isActive, "Listing is not active");
require(
duration >= listing.minDuration,
"Duration too short"
);
require(
duration <= listing.maxDuration,
"Duration too long"
);
// 現在レンタル中でないことを確認
IERC4907 nft = IERC4907(listing.nftContract);
require(
nft.userOf(listing.tokenId) == address(0),
"NFT is currently rented"
);
// レンタル料の計算(日数ベース、切り上げ)
uint256 days_ = (duration + 1 days - 1) / 1 days;
uint256 totalPrice = listing.pricePerDay * days_;
require(msg.value >= totalPrice, "Insufficient payment");
// 有効期限を設定
uint64 expires = uint64(block.timestamp + duration);
// ERC4907のsetUserを呼び出し
nft.setUser(listing.tokenId, msg.sender, expires);
// プラットフォーム手数料の計算
uint256 fee = (totalPrice * platformFeeBps) / 10000;
uint256 lenderPayment = totalPrice - fee;
// 貸し手への支払い
(bool sent, ) = payable(listing.lender).call{
value: lenderPayment
}("");
require(sent, "Payment to lender failed");
// 余分な支払いの返金
if (msg.value > totalPrice) {
(bool refunded, ) = payable(msg.sender).call{
value: msg.value - totalPrice
}("");
require(refunded, "Refund failed");
}
emit Rented(listingId, msg.sender, expires, totalPrice);
}
/// @notice 出品を取り下げる
function delistNFT(uint256 listingId) external {
Listing storage listing = listings[listingId];
require(listing.isActive, "Listing is not active");
require(
listing.lender == msg.sender,
"Not the lender"
);
listing.isActive = false;
emit Delisted(listingId);
}
/// @notice プラットフォーム手数料を変更する(オーナーのみ)
function setFee(uint256 _feeBps) external onlyOwner {
require(_feeBps <= MAX_FEE_BPS, "Fee too high");
platformFeeBps = _feeBps;
}
/// @notice プラットフォーム手数料を引き出す(オーナーのみ)
function withdrawFees() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No fees to withdraw");
(bool sent, ) = payable(owner()).call{ value: balance }("");
require(sent, "Withdrawal failed");
}
/// @notice 出品情報を取得する
function getListing(
uint256 listingId
) external view returns (Listing memory) {
return listings[listingId];
}
/// @notice レンタル料を計算する
function calculateRentalPrice(
uint256 listingId,
uint256 duration
) external view returns (uint256 totalPrice, uint256 fee) {
Listing storage listing = listings[listingId];
uint256 days_ = (duration + 1 days - 1) / 1 days;
totalPrice = listing.pricePerDay * days_;
fee = (totalPrice * platformFeeBps) / 10000;
}
}
コントラクトの設計ポイント
ReentrancyGuard: ETHの送金を伴う
rentNFT関数には、再入攻撃を防ぐnonReentrant修飾子を適用しています。pricePerDay: レンタル料を日単位で設定することで、柔軟な価格設定が可能です。期間は秒単位で指定し、日数に切り上げて計算します。
プラットフォーム手数料: basis points(1/100パーセント)で管理します。250 bps = 2.5%です。最大10%の上限を設定しています。
テスト
// test/RentalMarketplace.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
describe("RentalMarketplace", function () {
async function deployFixture() {
const [owner, lender, renter] = await ethers.getSigners();
// NFTコントラクトのデプロイ
const NFT = await ethers.getContractFactory("CulturalRentableNFT");
const nft = await NFT.deploy();
// マーケットプレイスのデプロイ(手数料 2.5%)
const Market = await ethers.getContractFactory("RentalMarketplace");
const market = await Market.deploy(250);
// NFTをミントしてlenderに付与
await nft.mint(lender.address, "ipfs://test-metadata");
// マーケットプレイスにapprove
const marketAddress = await market.getAddress();
await nft.connect(lender).approve(marketAddress, 0);
return { nft, market, owner, lender, renter };
}
it("NFTを出品できる", async function () {
const { nft, market, lender } = await deployFixture();
const nftAddress = await nft.getAddress();
const pricePerDay = ethers.parseEther("0.01"); // 0.01 ETH/日
await expect(
market.connect(lender).listForRent(
nftAddress,
0, // tokenId
pricePerDay,
7 * 24 * 3600, // 最大7日
1 * 3600 // 最小1時間
)
).to.emit(market, "Listed");
const listing = await market.getListing(0);
expect(listing.lender).to.equal(lender.address);
expect(listing.pricePerDay).to.equal(pricePerDay);
expect(listing.isActive).to.be.true;
});
it("NFTをレンタルできる", async function () {
const { nft, market, lender, renter } = await deployFixture();
const nftAddress = await nft.getAddress();
const pricePerDay = ethers.parseEther("0.01");
// 出品
await market.connect(lender).listForRent(
nftAddress, 0, pricePerDay, 7 * 24 * 3600, 1 * 3600
);
// 3日間レンタル
const duration = 3 * 24 * 3600;
const totalPrice = ethers.parseEther("0.03"); // 0.01 * 3日
const lenderBalanceBefore = await ethers.provider.getBalance(
lender.address
);
await expect(
market.connect(renter).rentNFT(0, duration, {
value: totalPrice,
})
).to.emit(market, "Rented");
// 使用者がrenterに設定されている
expect(await nft.userOf(0)).to.equal(renter.address);
// 貸し手に手数料差し引き後の金額が支払われている
const lenderBalanceAfter = await ethers.provider.getBalance(
lender.address
);
const expectedPayment =
totalPrice - (totalPrice * 250n) / 10000n;
expect(lenderBalanceAfter - lenderBalanceBefore).to.equal(
expectedPayment
);
});
it("レンタル中のNFTは再レンタルできない", async function () {
const { nft, market, lender, renter } = await deployFixture();
const nftAddress = await nft.getAddress();
const pricePerDay = ethers.parseEther("0.01");
await market.connect(lender).listForRent(
nftAddress, 0, pricePerDay, 7 * 24 * 3600, 1 * 3600
);
// 1日レンタル
await market.connect(renter).rentNFT(0, 24 * 3600, {
value: ethers.parseEther("0.01"),
});
// 再レンタルは失敗する
await expect(
market.connect(renter).rentNFT(0, 24 * 3600, {
value: ethers.parseEther("0.01"),
})
).to.be.revertedWith("NFT is currently rented");
});
it("出品を取り下げられる", async function () {
const { nft, market, lender } = await deployFixture();
const nftAddress = await nft.getAddress();
await market.connect(lender).listForRent(
nftAddress, 0, ethers.parseEther("0.01"),
7 * 24 * 3600, 1 * 3600
);
await expect(market.connect(lender).delistNFT(0))
.to.emit(market, "Delisted")
.withArgs(0);
const listing = await market.getListing(0);
expect(listing.isActive).to.be.false;
});
});
フロントエンド連携
マーケットプレイスのフロントエンドでは、出品一覧の表示とレンタル機能を実装します。
// src/components/RentalListings.tsx
"use client";
import { useState, useEffect } from "react";
import {
useReadContract,
useWriteContract,
useWaitForTransactionReceipt,
} from "wagmi";
// マーケットプレイスのABI(必要な関数のみ)
const MARKET_ABI = [
{
inputs: [{ name: "listingId", type: "uint256" }],
name: "getListing",
outputs: [{
components: [
{ name: "nftContract", type: "address" },
{ name: "tokenId", type: "uint256" },
{ name: "lender", type: "address" },
{ name: "pricePerDay", type: "uint256" },
{ name: "maxDuration", type: "uint256" },
{ name: "minDuration", type: "uint256" },
{ name: "isActive", type: "bool" },
],
type: "tuple",
}],
stateMutability: "view",
type: "function",
},
{
inputs: [
{ name: "listingId", type: "uint256" },
{ name: "duration", type: "uint256" },
],
name: "rentNFT",
outputs: [],
stateMutability: "payable",
type: "function",
},
] as const;
const MARKET_ADDRESS = process.env
.NEXT_PUBLIC_MARKET_ADDRESS as `0x${string}`;
interface RentalCardProps {
listingId: number;
}
export function RentalCard({ listingId }: RentalCardProps) {
const [rentalDays, setRentalDays] = useState(1);
const { data: listing } = useReadContract({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
functionName: "getListing",
args: [BigInt(listingId)],
});
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
if (!listing || !listing.isActive) return null;
const pricePerDay = listing.pricePerDay;
const totalPrice = pricePerDay * BigInt(rentalDays);
const duration = rentalDays * 24 * 3600;
function handleRent() {
writeContract({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
functionName: "rentNFT",
args: [BigInt(listingId), BigInt(duration)],
value: totalPrice,
});
}
return (
<div className="bg-gray-800 rounded-lg p-4">
<p className="text-sm text-gray-400">
Token ID: #{listing.tokenId.toString()}
</p>
<p className="text-lg font-semibold mt-2">
{(Number(pricePerDay) / 1e18).toFixed(4)} ETH / 日
</p>
<div className="flex items-center gap-2 mt-4">
<input
type="number"
min={1}
max={Number(listing.maxDuration) / 86400}
value={rentalDays}
onChange={(e) => setRentalDays(Number(e.target.value))}
className="w-20 px-2 py-1 bg-gray-700 rounded text-white"
/>
<span className="text-gray-400">日間</span>
</div>
<p className="text-sm text-gray-300 mt-2">
合計: {(Number(totalPrice) / 1e18).toFixed(4)} ETH
</p>
<button
onClick={handleRent}
disabled={isPending || isConfirming}
className="w-full mt-4 px-4 py-2 bg-purple-600 rounded
font-semibold hover:bg-purple-500
disabled:opacity-50"
>
{isPending ? "署名待ち..." : isConfirming ? "確認中..." : "レンタルする"}
</button>
{isSuccess && (
<p className="mt-2 text-green-400 text-sm">
レンタルが完了しました!
</p>
)}
</div>
);
}
マーケットプレイスの運用フロー
まとめ
本章では、ERC4907対応NFTのレンタルマーケットプレイスを構築しました。出品・レンタル・取り下げの機能、プラットフォーム手数料の管理、ReentrancyGuardによるセキュリティ対策を実装しました。
マーケットプレイスコントラクトは、NFTコントラクトから独立しているため、ERC4907に対応した任意のNFTコントラクトと組み合わせて使用できます。
次章では、IIIFとNFTを統合して、デジタル文化財のための高度なメタデータ管理を実装します。