マーケットプレイスの概要

前章でERC4907対応のレンタル可能NFTを実装しました。本章では、そのNFTを出品・レンタルできるマーケットプレイスコントラクトを構築します。NFTの所有者がレンタル条件(価格・期間)を設定して出品し、借り手がETHを支払ってレンタルするという仕組みです。

マーケットプレイスの機能

1234....NFT使NFrTednetlNiFslTtiNsFtTForRent

マーケットプレイスコントラクト

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

コントラクトの設計ポイント

  1. ReentrancyGuard: ETHの送金を伴う rentNFT 関数には、再入攻撃を防ぐ nonReentrant 修飾子を適用しています。

  2. pricePerDay: レンタル料を日単位で設定することで、柔軟な価格設定が可能です。期間は秒単位で指定し、日数に切り上げて計算します。

  3. プラットフォーム手数料: 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>
  );
}

マーケットプレイスの運用フロー

N1231234---F.......TuClrusuieselsnertt:trOuFNOfroFf(arT(:)lR()Re)ennt:t(Ea)TbH使laedNadFprTpersNosFv(Te0)

まとめ

本章では、ERC4907対応NFTのレンタルマーケットプレイスを構築しました。出品・レンタル・取り下げの機能、プラットフォーム手数料の管理、ReentrancyGuardによるセキュリティ対策を実装しました。

マーケットプレイスコントラクトは、NFTコントラクトから独立しているため、ERC4907に対応した任意のNFTコントラクトと組み合わせて使用できます。

次章では、IIIFとNFTを統合して、デジタル文化財のための高度なメタデータ管理を実装します。

関連記事