まいにちDapps#18 NFTのOnchain-Royaltyと独自マケプレの導入

ponta

ponta

· 5 min read
Thumbnail

つい先日、OpenSeaがNFTの二次流通に対するCreator Feeを強制ではなく取引するユーザーの任意にすることを発表しました。

Changes to creator fees on OpenSea

これに対して世間ではさまざまな反応が見られます。

Yuga LabsはOpenSeaのコントラクトをサポートしない方針を示しました。

Image

今回はNFTのロイヤリティについて改めて整理したいと思います。

NFTのロイヤリティ問題とOpenSeaの奮闘の歴史

NFTが登場した初期には、共通のロイヤリティ規格は存在していませんでした。各マーケットプレイスが独自の分配の仕組みを実装しており、クリエイターへの報酬の流れは一貫していませんでした。

NFTコレクションのコントラクトにクリエイターフィーの情報を含めるロイヤリティ標準規格「EIP-2981」が登場し、マーケットプレイス側がこの情報に基づいてクリエイターフィーを支払う対応を始めました。しかしOpenSeaの競合となるBlurなどはマーケット手数料を0%に、一時的にクリエイターフィーを0%にしてOpenSeaからシェアを奪っていきました。マーケットプレイスマーケットプレイス

2022年OpenSeaはOpenSea Operator Filterを導入し一部のマーケットプレイスをブロックするコントラクトを含まない場合、OpenSeaでのクリエイターフィーは0%になるとの発表がありました。

この辺りはmiinさんの以下の記事が詳しいです。

いま崩れはじめた「NFTはクリエイターにロイヤリティが還元され続ける」という神話

OpenSea vs Blur、クリエイターフィーの現状について


この頃のOpenSeaの方針(特に既存のコレクションに対する扱い)は二転三転しエンジニアとして対応に混乱したことを覚えています。

ERC2981について

改めてERC2981とそれに対応したマーケットプレイスのコントラクトを見てみます。

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/common/ERC2981.sol

以下のrolaytyInfoという関数を実行して返ってくる値が支払われるべきクリエイターフィーということです。

function royaltyInfo(uint256 tokenId, uint256 salePrice) public view virtual returns (address, uint256) {
    RoyaltyInfo memory royalty = _tokenRoyaltyInfo[tokenId];

    if (royalty.receiver == address(0)) {
        royalty = _defaultRoyaltyInfo;
    }

    uint256 royaltyAmount = (salePrice * royalty.royaltyFraction) / _feeDenominator();

    return (royalty.receiver, royaltyAmount);
}

こちらはマーケットのサンプルコントラクトです。

https://github.com/benber86/nft_royalties_market/blob/419571e1a966708785455e3ef3374d5d3be8b290/contracts/Marketplace.sol#L98

function _deduceRoyalties(uint256 tokenId, uint256 grossSaleValue)
internal returns (uint256 netSaleAmount) {
    // Get amount of royalties to pays and recipient
    (address royaltiesReceiver, uint256 royaltiesAmount) = token
    .royaltyInfo(tokenId, grossSaleValue);
    // Deduce royalties from sale value
    uint256 netSaleValue = grossSaleValue - royaltiesAmount;
    // Transfer royalties to rightholder if not zero
    if (royaltiesAmount > 0) {
        royaltiesReceiver.call{value: royaltiesAmount}('');
    }
    // Broadcast royalties payment
    emit RoyaltiesPaid(tokenId, royaltiesAmount);
    return netSaleValue;
}

今後の流れ予想

  1. コレクション独自のマーケットプレイス増加
  2. 二次流通ロイヤリティ以外のマネタイズ増加

以前はOpenSeaでの取引高がコレクションの人気の指標として重要視されていましたが、NFT市場全体の注目度の低下もさることながらOpenSea自体の影響度も落ちているような気がします。コレクション独自のページで独自のマーケットプレイスを持つことも多くなると思います。

収益の柱として考えず、他のマネタイズを狙うほうが健全と言えます。#まいにちDappsではNFTの活用方法をたくさん提供しているので、アイデアのタネを提供できれば幸いです。

コレクション独自のマーケットプレイスを作成する

ThirdwebのコントラクトとReactのSDKを使ってマーケットプレイスを作成してみましょう。

基本はこちらのブログを参考に進めますが、別画面で出品すると少しダサいので、OpenSeaのようにNFTの詳細画面内でリスティングしていきます。

Create Your Own NFT Marketplace with TypeScript and Next.js


まずマーケットのコントラクトをデプロイします。

https://thirdweb.com/thirdweb.eth/MarketplaceV3

今回の目的はNFTのロイヤリティの回収ですので、プラットフォームFeeを設定します。

Image

Next.jsのプロジェクトを立ち上げます。

一気に飛ばしてしまってNFT一覧画面をトップページに、詳細画面を以下のコードで作りました。

"use client";

import {
  useContract,
  ConnectWallet,
  useListings,
  useNFT,
  useAddress,
} from "@thirdweb-dev/react";
import { useState } from "react";

export default function NFT({ params }: { params: { tokenId: string } }) {
  const nftContractAddress = "0xE71eE93Ad57c8b355e1bCDFe435B05673d8B4930";
  const { contract } = useContract(
    "0xE71eE93Ad57c8b355e1bCDFe435B05673d8B4930"
  );

  const { contract: marketContract } = useContract(
    "0xaEa0d74cEFc8D75Fa42Ab1e49B2a7122620128fC",
    "marketplace"
  );

  const [sellingPrice, setSellingPrice] = useState("");

  const { data: nft, isLoading, error } = useNFT(contract, params.tokenId);

  const {
    data: listings,
    isLoading: listingLoding,
    error: listingError,
  } = useListings(marketContract, {
    tokenContract: nftContractAddress,
    tokenId: params.tokenId,
    start: 0,
    count: 100,
  });

  const address = useAddress();

  const handleCancel = async (listingId: string) => {
    await marketContract?.direct.cancelListing(listingId);
  };

  const handleSell = async () => {
    const listing = {
      assetContractAddress: nftContractAddress,
      tokenId: params.tokenId,
      startTimestamp: new Date(),
      listingDurationInSeconds: 86400,
      quantity: 1,
      currencyContractAddress: "0x0000000000000000000000000000000000000000",
      buyoutPricePerToken: sellingPrice,
    };
    await marketContract?.direct.createListing(listing);
  };

  const handleBuy = async (listingId: string) => {
    await marketContract?.direct.buyoutListing(listingId, 1);
  };

  return (
    <main>
      <div className="bg-white min-h-screen">
        <div className="mx-auto px-4 py-16 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8">
          {/* Product */}
          <div className="lg:grid lg:grid-cols-7 lg:grid-rows-1 lg:gap-x-8 lg:gap-y-10 xl:gap-x-16">
            {/* Product image */}
            <div className="lg:col-span-4 lg:row-end-1">
              <div className="aspect-h-4 aspect-w-4 overflow-hidden rounded-lg bg-gray-100">
                {isLoading ? (
                  <>...loading</>
                ) : (
                  <img
                    src={nft?.metadata.image!}
                    alt={nft?.metadata.name as string}
                    className="object-cover object-center mx-auto"
                  />
                )}
              </div>
            </div>

            {/* Product details */}
            <div className="mx-auto mt-14 max-w-2xl sm:mt-16 lg:col-span-3 lg:row-span-2 lg:row-end-2 lg:mt-0 lg:max-w-none">
              <div className="flex flex-col-reverse">
                <div className="mt-4">
                  <h1 className="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl">
                    {nft?.metadata.name}
                  </h1>

                  <h2 id="information-heading" className="sr-only">
                    Product information
                  </h2>
                </div>
              </div>

              <p className="mt-6 text-gray-500">{nft?.metadata.description}</p>
              {listings?.length && (
                <p className="mt-10 text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl">
                  {listings![0].buyoutCurrencyValuePerToken.displayValue}{" "}
                  {listings![0].buyoutCurrencyValuePerToken.symbol}
                </p>
              )}
              <div className="mt-2 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
                {address ? (
                  <>
                    {/* listingがあるとき */}
                    {listings?.length ? (
                      <>
                        {listings![0].sellerAddress != address ? (
                          <>
                            <button
                              onClick={() => handleBuy(listings[0].id)}
                              type="button"
                              className="flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50"
                            >
                              {/* listingがあり、売り手アドレスが自分以外の時 */}
                              Buy
                            </button>
                            <button
                              type="button"
                              className="flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-50 px-8 py-3 text-base font-medium text-indigo-700 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50"
                            >
                              {/* listingがなく、NFTオーナーが自分以外の時 */}
                              Offer
                            </button>
                          </>
                        ) : (
                          <button
                            onClick={() => handleCancel(listings[0].id)}
                            type="button"
                            className="flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50"
                          >
                            {/* listingがあり、売り手アドレスが自分の時 */}
                            Cancel
                          </button>
                        )}
                      </>
                    ) : (
                      <>
                        {/* listingがないとき */}
                        {nft?.owner == address ? (
                          <>
                            <div className="relative mt-2 rounded-md">
                              <input
                                type="text"
                                name="price"
                                id="price"
                                className="block w-full rounded-md border-0 py-3 pl-7 pr-12 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
                                placeholder="0.00"
                                aria-describedby="price-currency"
                                onChange={(e) => {
                                  setSellingPrice(e.target.value);
                                }}
                              />
                              <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
                                <span
                                  className="text-gray-500 sm:text-sm"
                                  id="price-currency"
                                >
                                  MATIC
                                </span>
                              </div>
                            </div>
                            <button
                              onClick={handleSell}
                              type="button"
                              className="flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50"
                            >
                              {/* listingがなく、NFTオーナーが自分の時 */}
                              Sell
                            </button>
                          </>
                        ) : (
                          <button
                            type="button"
                            className="flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-50"
                          >
                            {/* listingがなく、NFTオーナーが自分以外の時 */}
                            Offer
                          </button>
                        )}
                      </>
                    )}
                  </>
                ) : (
                  <ConnectWallet></ConnectWallet>
                )}
              </div>
            </div>
          </div>
        </div>
      </div>
    </main>
  );
}

Image

ロジックとしては

  • ウォレットを接続していなければConnect Walletボタンを表示
  • 当該NFTのリスティングがない場合で
    • 当該NFTのオーナーが自分の場合、Sellボタン
    • 当該NFTのオーナーが自分以外の場合、Offerボタン
  • 当該NFTのリスティングがある場合で
    • 当該NFTのオーナーが自分の場合、Cancelボタン
    • 当該NFTのオーナーが自分以外の場合、Buyボタン + Offerボタン

という出し分けになります。(本当はあとOffer Acceptも必要ですね)

マーケットコントラクトのロジックはこちらを参照してください。

また、今回のコードは自前のデータベースを持たず、オンチェーンの情報をフロントエンドから都度参照しているので、パフォーマンスは低いものになっています。

まとめ

今回はNFTのロイヤリティ問題の詳細に触れ、ざっくり独自マーケットプレイスを作成する方法について解説しました。本番用に調整したコードでPontechホームページにも導入しようと考えています。

弊社Pontechはweb3に関わる開発を得意とするテック企業です。サービス開発に関するご相談はこちらのフォームからお願いいたします。

また、受託開発案件に共に取り組むメンバーを募集しています!ご興味のある方はぜひお話させてください!

ponta

About ponta

2019年からEthereumを中心にDapp開発に従事。スキーとNBAとTWICEが好き。

Copyright © 2024 Pontech.Inc