マーケットプレイスの概要 前章でERC4907対応のレンタル可能NFTを実装しました。本章では、そのNFTを出品・レンタルできるマーケットプレイスコントラクトを構築します。NFTの所有者がレンタル条件(価格・期間)を設定して出品し、借り手がETHを支払ってレンタルするという仕組みです。
マーケットプレイスの機能 1 2 3 4 . . . . N 借 レ 所 F り ン 有 T 手 タ 者 所 が ル が 有 レ 期 い 者 ン 間 つ が タ が で マ ル 終 も ー 料 了 出 ケ を す 品 ッ 支 る を ト 払 と 取 プ っ 自 り レ て 動 下 イ 借 的 げ ス り に ら に る 使 れ N ( 用 る F r 権 ( T e が d を n 失 e 出 t 効 l 品 N i ( F s l T t i ) N s F t T F ) o r R e n t ) マーケットプレイスコントラクト // 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 修飾子を適用しています。
...