Как создать NFT-маркетплейс с полным стеком

Чтобы познакомиться с V1 этого руководства, нажмите здесь. Чтобы просмотреть видеокурс к нему, кликните здесь.

Из этого руководства вы узнаете, как разработать, развернуть и протестировать полнофункциональный NFT-маркетплейс на Ethereum. Мы также рассмотрим развертывание на Polygon.

Уже нельзя не замечать того, как быстро набирают обороты и внедряются такие решения по Ethereum-масштабированию, как Polygon, Arbitrum и Optimism. Эти технологии позволяют разработчикам создавать те же приложения, что и непосредственно на Ethereum, только с дополнительными преимуществами в виде более низкой стоимости газа и более высокой скорости транзакций.

Учитывая выгодность этих технологий, а также отсутствие руководств по ним, я буду создавать проекты и туториалы по разработке полнофункциональных приложений с использованием различных решений по Ethereum-масштабированию. В этой статье начну с проекта на Polygon.

Просмотреть окончательный исходный код данного проекта можно, открыв этот репозиторий.

Предварительные условия

Для успешного следования данному руководству необходимо подготовить следующее.

  • Фреймворк Node.js (версии 16.14.0 или более поздней), установленный на вашем компьютере. Рекомендую устанавливать Node с помощью nvm или fnm.
  • Расширение для кошелька Metamask, установленное в качестве расширения браузера.

Стек

Это руководство предполагает создание приложения с полным стеком.

  • Фреймворк для веб-приложений  —  Next.js.
  • Среда разработки Solidity — Hardhat.
  • Файловое хранилище — IPFS.
  • Библиотека веб-клиента Ethereum — Ethers.js.

Хотя это не входит в состав данного руководства, я покажу, как создать более надежный уровень API с использованием протокола The Graph. Это позволит обойти ограничения в шаблонах доступа к данным, предоставляемых нативным уровнем блокчейна.

О проекте

Проект, который мы будем создавать, называется Metaverse Marketplace. Он представляет собой NFT-маркетплейс.

Когда пользователь выставляет NFT на продажу, право собственности на предмет переходит от создателя к контракту на маркетплейсе.

Когда пользователь покупает NFT, стоимость покупки переходит от покупателя к продавцу, а позиция переходит из маркетплейса к покупателю.

Владелец маркетплейса может установить плату за листинг. Она будет взиматься с продавца и перечисляться владельцу контракта после завершения продажи. Это позволит владельцу маркетплейса получать постоянный доход от любой продажи, совершенной на маркетплейсе.

Логика работы маркетплейса будет состоять всего из одного смарт-контракта  —  NFT Marketplace Contract. Такой контракт позволяет пользователям майнить NFT и размещать их на торговой площадке.

Мне нравится данный проект тем, что использованные в нем инструменты, методы и идеи закладывают основу для многих других типов приложений на этом стеке, связанных с такими элементами, как платежи, комиссии и передача прав собственности на уровне контракта, а также с возможностью использовать этот смарт-контракт для создания производительного и приятного на вид пользовательского интерфейса для клиентского приложения.

В дополнение к смарт-контракту я также покажу, как построить подграф, чтобы сделать запрос данных из смарт-контракта более гибким и эффективным. Как вы увидите, создание представлений для наборов данных и обеспечение вариативных и высокопроизводительных шаблонов доступа к данным сложно реализовать непосредственно из смарт-контракта. The Graph значительно упрощает эту задачу.

О Polygon

Из документации:

“Polygon  —  это протокол и фреймворк для создания и соединения блокчейн-сетей, совместимых с Ethereum. Агрегирование масштабируемых решений на Ethereum с поддержкой многоцепочечной экосистемы Ethereum”.

Polygon примерно в 10 раз быстрее, чем Ethereum, и при этом транзакции на Polygon более чем в 10 раз дешевле.

Что все это значит? Для меня это означает, что я могу использовать те же знания, инструменты и технологии, которые я применял для создания приложений на Ethereum, в процессе построения приложений, которые будут быстрее и дешевле для пользователей. В результате я не только обеспечу оптимальный пользовательский опыт, но и получу возможность разработки многих типов приложений, которые просто невозможно было бы создать непосредственно на Ethereum.

Как уже упоминалось, существует множество других решений для Ethereum-масштабирования, таких как Arbitrum и Optimism, которые обладают похожим функционалом. Большинство этих решений по масштабированию имеют технические различия и относятся к различным категориям, таким как сайдчейн, второй уровень и каналы состояния.

Компания Polygon образовалась в результате ребрендинга Matic, поэтому слово Matic используется как взаимозаменяемое при упоминании о Polygon. Оно все еще служит названием для некоторых частей экосистемы, например используется в названиях ее токенов и сетей.

Чтобы узнать больше о Polygon, ознакомьтесь с их документацией здесь.

Теперь, после обзора проекта и связанных с ним технологий, приступим к разработке!

Настройка проекта

Чтобы начать работу, создадим новое приложение Next.js. Для этого откройте терминал. Создайте или перейдите в новый пустой каталог и выполните следующую команду:

npx create-next-app nft-marketplace

Затем перейдите в новый каталог и установите зависимости с помощью менеджера пакетов, например npm, yarn или pnpm:

cd nft-marketplace
npm install ethers hardhat @nomiclabs/hardhat-waffle \
ethereum-waffle chai @nomiclabs/hardhat-ethers \
web3modal @openzeppelin/contracts ipfs-http-client \
axios

Настройка Tailwind CSS

Для стилизации будем использовать Tailwind CSS. Настроим его на этом этапе.

Tailwind  —  это CSS-фреймворк на основе концепции utility-first, который позволяет легко добавлять стили и создавать красивые сайты без особых усилий.

Итак, установим зависимости Tailwind:

npm install -D tailwindcss@latest postcss@latest autoprefixer@latest

Далее создадим конфигурационные файлы, необходимые для работы Tailwind с Next.js (tailwind.config.js и postcss.config.js), выполнив следующую команду:

npx tailwindcss init -p

Затем настроим пути к шаблону content в файле tailwind.config.js:

/* tailwind.config.js */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

Наконец, удалим код в styles/globals.css и обновим его следующим образом:

@tailwind base;
@tailwind components;
@tailwind utilities;

Настройка Hardhat

Далее инициализируйте новую среду разработки Hardhat из корня проекта:

npx hardhat

? What do you want to do? Create a basic sample project
? Hardhat project root: <Choose default path>

Если получите ошибку при ссылке на README.md, удалите README.md и запустите npx hardhat снова.

В корневом каталоге вы увидите следующие файлы и папки.

  • hardhat.config.js  —  файл, где содержится все настройки Hardhat, т.е. конфиг, плагины и настраиваемые задачи.
  • scripts  —  папка, содержащая скрипт под названием sample-script.js, который при выполнении развернет смарт-контракт.
  • test  —  папка, содержащая пример скрипта тестирования.
  • contracts  —  папка, содержащая пример смарт-контракта Solidity.

Теперь обновите конфигурацию в файле hardhat.config.js следующим образом:

/* hardhat.config.js */
require("@nomiclabs/hardhat-waffle")

module.exports = {
  defaultNetwork: "hardhat",
  networks: {
    hardhat: {
      chainId: 1337
    },
//  закомментирована неиспользуемая конфигурация
//  mumbai: {
//    url: "https://rpc-mumbai.maticvigil.com",
//    accounts: [process.env.privateKey]
//  }
  },
  solidity: {
    version: "0.8.4",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  }
}

Смотрите код на GitHub здесь.

В этой конфигурации мы настроили локальную среду разработки Hardhat, а также тестовую сеть Mumbai (на данный момент закомментированную).

Подробнее об обеих сетях Matic можно прочитать здесь.

Смарт-контракт

Далее создадим смарт-контракт!

В этом файле я постараюсь максимально прокомментировать все, что происходит в коде.

Создайте новый файл в каталоге contracts под названием NFTMarketplace.sol. Добавьте в него следующий код:

// Идентификатор SPDX-лицензии: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

import "hardhat/console.sol";

contract NFTMarketplace is ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
Counters.Counter private _itemsSold;

uint256 listingPrice = 0.025 ether;
address payable owner;

mapping(uint256 => MarketItem) private idToMarketItem;

struct MarketItem {
uint256 tokenId;
address payable seller;
address payable owner;
uint256 price;
bool sold;
}

event MarketItemCreated (
uint256 indexed tokenId,
address seller,
address owner,
uint256 price,
bool sold
);

constructor() ERC721("Metaverse Tokens", "METT") {
owner = payable(msg.sender);
}

/*
Обновление листинговой цены контракта */
function updateListingPrice(uint _listingPrice) public payable {
require(owner == msg.sender, "Only marketplace owner can update listing price.");
listingPrice = _listingPrice;
}

/*
Возврат листинговой цены контракта */
function getListingPrice() public view returns (uint256) {
return listingPrice;
}

/*
Чеканка токена и его листинг на маркетплейсе */
function createToken(string memory tokenURI, uint256 price) public payable returns (uint) {
_tokenIds.increment();
uint256 newTokenId = _tokenIds.current();

_mint(msg.sender, newTokenId);
_setTokenURI(newTokenId, tokenURI);
createMarketItem(newTokenId, price);
return newTokenId;
}

function createMarketItem(
uint256 tokenId,
uint256 price
) private {
require(price > 0, "Price must be at least 1 wei");
require(msg.value == listingPrice, "Price must be equal to listing price");

idToMarketItem[tokenId] = MarketItem(
tokenId,
payable(msg.sender),
payable(address(this)),
price,
false
);

_transfer(msg.sender, address(this), tokenId);
emit MarketItemCreated(
tokenId,
msg.sender,
address(this),
price,
false
);
}

/* Возможность
перепродажи приобретенного токена */
function resellToken(uint256 tokenId, uint256 price) public payable {
require(idToMarketItem[tokenId].owner == msg.sender, "Only item owner can perform this operation");
require(msg.value == listingPrice, "Price must be equal to listing price");
idToMarketItem[tokenId].sold = false;
idToMarketItem[tokenId].price = price;
idToMarketItem[tokenId].seller = payable(msg.sender);
idToMarketItem[tokenId].owner = payable(address(this));
_itemsSold.decrement();

_transfer(msg.sender, address(this), tokenId);
}

/* Открытие
продажи позиции на маркетплейсе */
/*
Передача права собственности на позицию, а также денежных средств между сторонами */
function createMarketSale(
uint256 tokenId
) public payable {
uint price = idToMarketItem[tokenId].price;
address seller = idToMarketItem[tokenId].seller;
require(msg.value == price, "Please submit the asking price in order to complete the purchase");
idToMarketItem[tokenId].owner = payable(msg.sender);
idToMarketItem[tokenId].sold = true;
idToMarketItem[tokenId].seller = payable(address(0));
_itemsSold.increment();
_transfer(address(this), msg.sender, tokenId);
payable(owner).transfer(listingPrice);
payable(seller).transfer(msg.value);
}

/*
Возврат всех непроданных позиций */
function fetchMarketItems() public view returns (MarketItem[] memory) {
uint itemCount = _tokenIds.current();
uint unsoldItemCount = _tokenIds.current() - _itemsSold.current();
uint currentIndex = 0;

MarketItem[] memory items = new MarketItem[](unsoldItemCount);
for (uint i = 0; i < itemCount; i++) {
if (idToMarketItem[i + 1].owner == address(this)) {
uint currentId = i + 1;
MarketItem storage currentItem = idToMarketItem[currentId];
items[currentIndex] = currentItem;
currentIndex += 1;
}
}
return items;
}

/*
Возврат только тех позиций, которые были приобретены пользователем */
function fetchMyNFTs() public view returns (MarketItem[] memory) {
uint totalItemCount = _tokenIds.current();
uint itemCount = 0;
uint currentIndex = 0;

for (uint i = 0; i < totalItemCount; i++) {
if (idToMarketItem[i + 1].owner == msg.sender) {
itemCount += 1;
}
}

MarketItem[] memory items = new MarketItem[](itemCount);
for (uint i = 0; i < totalItemCount; i++) {
if (idToMarketItem[i + 1].owner == msg.sender) {
uint currentId = i + 1;
MarketItem storage currentItem = idToMarketItem[currentId];
items[currentIndex] = currentItem;
currentIndex += 1;
}
}
return items;
}

/*
Возврат только тех позиций, который разместил пользователь */
function fetchItemsListed() public view returns (MarketItem[] memory) {
uint totalItemCount = _tokenIds.current();
uint itemCount = 0;
uint currentIndex = 0;

for (uint i = 0; i < totalItemCount; i++) {
if (idToMarketItem[i + 1].seller == msg.sender) {
itemCount += 1;
}
}

MarketItem[] memory items = new MarketItem[](itemCount);
for (uint i = 0; i < totalItemCount; i++) {
if (idToMarketItem[i + 1].seller == msg.sender) {
uint currentId = i + 1;
MarketItem storage currentItem = idToMarketItem[currentId];
items[currentIndex] = currentItem;
currentIndex += 1;
}
}
return items;
}
}

Смотрите код на GitHub здесь.

В этом контракте мы следуем ERC721-стандарту, реализованному OpenZeppelin.

Теперь, когда работа над кодом и средой смарт-контракта завершена, можно протестировать его.

Для этого создадим локальный тест, который будет выполнять большую часть функций, таких как выпуск токена, выставление его на продажу, продажа позиции пользователю и запрос токенов.

Чтобы создать тест, откройте test/sample-test.js и обновите его следующим кодом:

/* test/sample-test.js */
describe("NFTMarket", function() {
it("Should create and execute market sales", async function() {
/*
развертывание маркетплейса */
const NFTMarketplace = await ethers.getContractFactory("NFTMarketplace")
const nftMarketplace = await NFTMarketplace.deploy()
await nftMarketplace.deployed()

let listingPrice = await nftMarketplace.getListingPrice()
listingPrice = listingPrice.toString()

const auctionPrice = ethers.utils.parseUnits('1', 'ether')

/* создание двух токенов */
await nftMarketplace.createToken("https://www.mytokenlocation.com", auctionPrice, { value: listingPrice })
await nftMarketplace.createToken("https://www.mytokenlocation2.com", auctionPrice, { value: listingPrice })

const [_, buyerAddress] = await ethers.getSigners()

/* выполнение продажи токена другому пользователю */
await nftMarketplace.connect(buyerAddress).createMarketSale(1, { value: auctionPrice })

/* перепродажа токена */
await nftMarketplace.connect(buyerAddress).resellToken(1, auctionPrice, { value: listingPrice })

/*
запрос и возврат непроданных позиций */
items = await nftMarketplace.fetchMarketItems()
items = await Promise.all(items.map(async i => {
const tokenUri = await nftMarketplace.tokenURI(i.tokenId)
let item = {
price: i.price.toString(),
tokenId: i.tokenId.toString(),
seller: i.seller,
owner: i.owner,
tokenUri
}
return item
}))
console.log('items: ', items)
})
})

Смотрите код на GitHub здесь.

Затем запустите тест из командной строки:

npx hardhat test

При успешном выполнении теста должен произойти вывод массива, содержащего две позиции маркетплейса.

Разработка фронтенда

Теперь, когда смарт-контракт работает и готов к использованию, можно приступить к созданию пользовательского интерфейса.

Первое, о чем стоит подумать,  —  настройка макета. Она позволит подключить навигацию, которая будет сохраняться на всех страницах.

Для этого откройте файл pages/_app.js и обновите его следующим кодом:

/* pages/_app.js */
import '../styles/globals.css'
import Link from 'next/link'

function MyApp({ Component, pageProps }) {
  return (
    <div>
      <nav className="border-b p-6">
        <p className="text-4xl font-bold">Metaverse Marketplace</p>
        <div className="flex mt-4">
          <Link href="/">
            <a className="mr-4 text-pink-500">
              Home
            </a>
          </Link>
          <Link href="/create-nft">
            <a className="mr-6 text-pink-500">
              Sell NFT
            </a>
          </Link>
          <Link href="/my-nfts">
            <a className="mr-6 text-pink-500">
              My NFTs
            </a>
          </Link>
          <Link href="/dashboard">
            <a className="mr-6 text-pink-500">
              Dashboard
            </a>
          </Link>
        </div>
      </nav>
      <Component {...pageProps} />
    </div>
  )
}

export default MyApp

Смотрите код на GitHub здесь.

Навигация содержит ссылки на домашний маршрут, а также страницу для продажи NFT, просмотра купленных вами NFT и дашборд для просмотра NFT, выставленных вами на продажу.

Запрос контракта для позиций на маркетплейсе

Следующей страницей, которую мы обновим, будет pages/index.js. Это главная точка входа в приложение, и именно здесь мы будем запрашивать продаваемые NFT и выводить их на экран.

/* pages/index.js */
import { ethers } from 'ethers'
import { useEffect, useState } from 'react'
import axios from 'axios'
import Web3Modal from 'web3modal'

import {
marketplaceAddress
} from '../config'

import NFTMarketplace from '../artifacts/contracts/NFTMarketplace.sol/NFTMarketplace.json'

export default function Home() {
const [nfts, setNfts] = useState([])
const [loadingState, setLoadingState] = useState('not-loaded')
useEffect(() => {
loadNFTs()
}, [])
async function loadNFTs() {
/*
создание общего провайдера и запрос непроданных позиций */
const provider = new ethers.providers.JsonRpcProvider()
const contract = new ethers.Contract(marketplaceAddress, NFTMarketplace.abi, provider)
const data = await contract.fetchMarketItems()

/*
*
отображение позиций, полученных из смарт-контракта, и их
форматирование,
*
а также получение метаданных их токенов
*/
const items = await Promise.all(data.map(async i => {
const tokenUri = await contract.tokenURI(i.tokenId)
const meta = await axios.get(tokenUri)
let price = ethers.utils.formatUnits(i.price.toString(), 'ether')
let item = {
price,
tokenId: i.tokenId.toNumber(),
seller: i.seller,
owner: i.owner,
image: meta.data.image,
name: meta.data.name,
description: meta.data.description,
}
return item
}))
setNfts(items)
setLoadingState('loaded')
}
async function buyNft(nft) {
/* Н
еобходимо, чтобы пользователь подписал транзакцию, поэтому будет использоваться Web3Provider для подписи */
const web3Modal = new Web3Modal()
const connection = await web3Modal.connect()
const provider = new ethers.providers.Web3Provider(connection)
const signer = provider.getSigner()
const contract = new ethers.Contract(marketplaceAddress, NFTMarketplace.abi, signer)

/* П
ользователю будет предложено оплатить запрос для завершения транзакции */
const price = ethers.utils.parseUnits(nft.price.toString(), 'ether')
const transaction = await contract.createMarketSale(nft.tokenId, {
value: price
})
await transaction.wait()
loadNFTs()
}
if (loadingState === 'loaded' && !nfts.length) return (<h1 className="px-20 py-10 text-3xl">No items in marketplace</h1>)
return (
<div className="flex justify-center">
<div className="px-4" style={{ maxWidth: '1600px' }}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4">
{
nfts.map((nft, i) => (
<div key={i} className="border shadow rounded-xl overflow-hidden">
<img src={nft.image} />
<div className="p-4">
<p style={{ height: '64px' }} className="text-2xl font-semibold">{nft.name}</p>
<div style={{ height: '70px', overflow: 'hidden' }}>
<p className="text-gray-400">{nft.description}</p>
</div>
</div>
<div className="p-4 bg-black">
<p className="text-2xl font-bold text-white">{nft.price} ETH</p>
<button className="mt-4 w-full bg-pink-500 text-white font-bold py-2 px-12 rounded" onClick={() => buyNft(nft)}>Buy</button>
</div>
</div>
))
}
</div>
</div>
</div>
)
}

Смотрите код на GitHub здесь.

Когда страница загрузится, мы запросим смарт-контракт для всех непроданных NFT и выведем их на экран вместе с метаданными о позициях и кнопкой для их покупки.

Создание и листинг NFT 

Далее создадим страницу, которая позволит пользователям создавать NFT и выставлять их на продажу.

Эта страница позволит пользователю выполнять несколько функций: 

  1. Загрузка и сохранение файлов в IPFS.
  2. Создание нового NFT.
  3. Настройка метаданных и цены позиции, а также выставление позиции на продажу на маркетплейсе. 

После того, как пользователь создаст и выставит позицию, он будет перенаправлен на главную страницу для просмотра всех позиций, выставленных на продажу.

Смотрите код на GitHub здесь.

/* pages/create-nft.js */
import { useState } from 'react'
import { ethers } from 'ethers'
import { create as ipfsHttpClient } from 'ipfs-http-client'
import { useRouter } from 'next/router'
import Web3Modal from 'web3modal'

const client = ipfsHttpClient('https://ipfs.infura.io:5001/api/v0')

import {
  marketplaceAddress
} from '../config'

import NFTMarketplace from '../artifacts/contracts/NFTMarketplace.sol/NFTMarketplace.json'

export default function CreateItem() {
  const [fileUrl, setFileUrl] = useState(null)
  const [formInput, updateFormInput] = useState({ price: '', name: '', description: '' })
  const router = useRouter()

  async function onChange(e) {
    /* Загрузка изображения в IPFS */
    const file = e.target.files[0]
    try {
      const added = await client.add(
        file,
        {
          progress: (prog) => console.log(`received: ${prog}`)
        }
      )
      const url = `https://ipfs.infura.io/ipfs/${added.path}`
      setFileUrl(url)
    } catch (error) {
      console.log('Error uploading file: ', error)
    }  
  }
  async function uploadToIPFS() {
    const { name, description, price } = formInput
    if (!name || !description || !price || !fileUrl) return
    /* Прежде всего, загрузка метаданных в IPFS */
    const data = JSON.stringify({
      name, description, image: fileUrl
    })
    try {
      const added = await client.add(data)
      const url = `https://ipfs.infura.io/ipfs/${added.path}`
      /* После загрузки метаданных в IPFS следует вернуть URL для использования в транзакции */
      return url
    } catch (error) {
      console.log('Error uploading file: ', error)
    }  
  }

  async function listNFTForSale() {
    const url = await uploadToIPFS()
    const web3Modal = new Web3Modal()
    const connection = await web3Modal.connect()
    const provider = new ethers.providers.Web3Provider(connection)
    const signer = provider.getSigner()

    /* Создание NFT */
    const price = ethers.utils.parseUnits(formInput.price, 'ether')
    let contract = new ethers.Contract(marketplaceAddress, NFTMarketplace.abi, signer)
    let listingPrice = await contract.getListingPrice()
    listingPrice = listingPrice.toString()
    let transaction = await contract.createToken(url, price, { value: listingPrice })
    await transaction.wait()

    router.push('/')
  }

  return (
    <div className="flex justify-center">
      <div className="w-1/2 flex flex-col pb-12">
        <input 
          placeholder="Asset Name"
          className="mt-8 border rounded p-4"
          onChange={e => updateFormInput({ ...formInput, name: e.target.value })}
        />
        <textarea
          placeholder="Asset Description"
          className="mt-2 border rounded p-4"
          onChange={e => updateFormInput({ ...formInput, description: e.target.value })}
        />
        <input
          placeholder="Asset Price in Eth"
          className="mt-2 border rounded p-4"
          onChange={e => updateFormInput({ ...formInput, price: e.target.value })}
        />
        <input
          type="file"
          name="Asset"
          className="my-4"
          onChange={onChange}
        />
        {
          fileUrl && (
            <img className="rounded mt-4" width="350" src={fileUrl} />
          )
        }
        <button onClick={listNFTForSale} className="font-bold mt-4 bg-pink-500 text-white rounded p-4 shadow-lg">
          Create NFT
        </button>
      </div>
    </div>
  )
}

Просмотр только тех NFT, что приобретены пользователем

В смарт-контракте NFTMarketplace.sol мы создали функцию fetchMyNFTs, которая возвращает только NFT, принадлежащие пользователю.

В pages/my-nfts.js будем использовать эту функцию для их получения и отображения.

Такая функциональность отличается от работы основной страницы запросов pages/index.js, поскольку нам нужно запросить у пользователя его адрес и использовать его в контракте, пользователь должен подписать транзакцию, чтобы функция получила ее соответствующим образом.

/* pages/my-nfts.js */
import { ethers } from 'ethers'
import { useEffect, useState } from 'react'
import axios from 'axios'
import Web3Modal from 'web3modal'
import { useRouter } from 'next/router'

import {
marketplaceAddress
} from '../config'

import NFTMarketplace from '../artifacts/contracts/NFTMarketplace.sol/NFTMarketplace.json'

export default function MyAssets() {
const [nfts, setNfts] = useState([])
const [loadingState, setLoadingState] = useState('not-loaded')
const router = useRouter()
useEffect(() => {
loadNFTs()
}, [])
async function loadNFTs() {
const web3Modal = new Web3Modal({
network: "mainnet",
cacheProvider: true,
})
const connection = await web3Modal.connect()
const provider = new ethers.providers.Web3Provider(connection)
const signer = provider.getSigner()

const marketplaceContract = new ethers.Contract(marketplaceAddress, NFTMarketplace.abi, signer)
const data = await marketplaceContract.fetchMyNFTs()

const items = await Promise.all(data.map(async i => {
const tokenURI = await marketplaceContract.tokenURI(i.tokenId)
const meta = await axios.get(tokenURI)
let price = ethers.utils.formatUnits(i.price.toString(), 'ether')
let item = {
price,
tokenId: i.tokenId.toNumber(),
seller: i.seller,
owner: i.owner,
image: meta.data.image,
tokenURI
}
return item
}))
setNfts(items)
setLoadingState('loaded')
}
function listNFT(nft) {
router.push(`/resell-nft?id=${nft.tokenId}&tokenURI=${nft.tokenURI}`)
}
if (loadingState === 'loaded' && !nfts.length) return (<h1 className="py-10 px-20 text-3xl">No NFTs owned</h1>)
return (
<div className="flex justify-center">
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4">
{
nfts.map((nft, i) => (
<div key={i} className="border shadow rounded-xl overflow-hidden">
<img src={nft.image} className="rounded" />
<div className="p-4 bg-black">
<p className="text-2xl font-bold text-white">Price - {nft.price} Eth</p>
<button className="mt-4 w-full bg-pink-500 text-white font-bold py-2 px-12 rounded" onClick={() => listNFT(nft)}>List</button>
</div>
</div>
))
}
</div>
</div>
</div>
)
}

Смотрите код на GitHub здесь.

Дашборд

Следующая страница, которую мы создадим,  —  это дашборд, который позволит пользователю просматривать все позиции, выставленные им на продажу. 

Эта страница будет использовать функцию fetchItemsListed из смарт-контракта NFTMarketplace.sol, которая возвращает только те позиции, которые соответствуют адресу пользователя, выполняющего вызов функции.

Создайте новый файл dashboard.js в каталоге pages со следующим кодом:

/* pages/dashboard.js */
import { ethers } from 'ethers'
import { useEffect, useState } from 'react'
import axios from 'axios'
import Web3Modal from 'web3modal'

import {
marketplaceAddress
} from '../config'

import NFTMarketplace from '../artifacts/contracts/NFTMarketplace.sol/NFTMarketplace.json'

export default function CreatorDashboard() {
const [nfts, setNfts] = useState([])
const [loadingState, setLoadingState] = useState('not-loaded')
useEffect(() => {
loadNFTs()
}, [])
async function loadNFTs() {
const web3Modal = new Web3Modal({
network: 'mainnet',
cacheProvider: true,
})
const connection = await web3Modal.connect()
const provider = new ethers.providers.Web3Provider(connection)
const signer = provider.getSigner()

const contract = new ethers.Contract(marketplaceAddress, NFTMarketplace.abi, signer)
const data = await contract.fetchItemsListed()

const items = await Promise.all(data.map(async i => {
const tokenUri = await contract.tokenURI(i.tokenId)
const meta = await axios.get(tokenUri)
let price = ethers.utils.formatUnits(i.price.toString(), 'ether')
let item = {
price,
tokenId: i.tokenId.toNumber(),
seller: i.seller,
owner: i.owner,
image: meta.data.image,
}
return item
}))

setNfts(items)
setLoadingState('loaded')
}
if (loadingState === 'loaded' && !nfts.length) return (<h1 className="py-10 px-20 text-3xl">No NFTs listed</h1>)
return (
<div>
<div className="p-4">
<h2 className="text-2xl py-2">Items Listed</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 pt-4">
{
nfts.map((nft, i) => (
<div key={i} className="border shadow rounded-xl overflow-hidden">
<img src={nft.image} className="rounded" />
<div className="p-4 bg-black">
<p className="text-2xl font-bold text-white">Price - {nft.price} Eth</p>
</div>
</div>
))
}
</div>
</div>
</div>
)
}

Смотрите код на GitHub здесь.

Перепродажа токена

Последняя страница, которую мы создадим, позволит пользователю перепродать NFT, приобретенный у кого-то другого.

Эта страница будет использовать функцию resellToken из смарт-контракта NFTMarketplace.sol.

/* pages/resell-nft.js */
import { useEffect, useState } from 'react'
import { ethers } from 'ethers'
import { useRouter } from 'next/router'
import axios from 'axios'
import Web3Modal from 'web3modal'

import {
marketplaceAddress
} from '../config'

import NFTMarketplace from '../artifacts/contracts/NFTMarketplace.sol/NFTMarketplace.json'

export default function ResellNFT() {
const [formInput, updateFormInput] = useState({ price: '', image: '' })
const router = useRouter()
const { id, tokenURI } = router.query
const { image, price } = formInput

useEffect(() => {
fetchNFT()
}, [id])

async function fetchNFT() {
if (!tokenURI) return
const meta = await axios.get(tokenURI)
updateFormInput(state => ({ ...state, image: meta.data.image }))
}

async function listNFTForSale() {
if (!price) return
const web3Modal = new Web3Modal()
const connection = await web3Modal.connect()
const provider = new ethers.providers.Web3Provider(connection)
const signer = provider.getSigner()

const priceFormatted = ethers.utils.parseUnits(formInput.price, 'ether')
let contract = new ethers.Contract(marketplaceAddress, NFTMarketplace.abi, signer)
let listingPrice = await contract.getListingPrice()

listingPrice = listingPrice.toString()
let transaction = await contract.resellToken(id, priceFormatted, { value: listingPrice })
await transaction.wait()

router.push('/')
}

return (
<div className="flex justify-center">
<div className="w-1/2 flex flex-col pb-12">
<input
placeholder="Asset Price in Eth"
className="mt-2 border rounded p-4"
onChange={e => updateFormInput({ ...formInput, price: e.target.value })}
/>
{
image && (
<img className="rounded mt-4" width="350" src={image} />
)
}
<button onClick={listNFTForSale} className="font-bold mt-4 bg-pink-500 text-white rounded p-4 shadow-lg">
List NFT
</button>
</div>
</div>
)
}

Смотрите код на GitHub здесь.

Запуск проекта

Чтобы запустить проект, нам понадобится скрипт развертывания для разворачивания смарт-контрактов в блокчейн-сети.

Развертывание контрактов в локальной сети

При создании проекта Hardhat сгенерировал пример скрипта развертывания в scripts/sample-script.js.

Чтобы четче обозначить назначение этого скрипта, измените название scripts/sample-script.js на scripts/deploy.js.

Затем обновите scripts/deploy.js следующим кодом:

const hre = require("hardhat");
const fs = require('fs');

async function main() {
const NFTMarketplace = await hre.ethers.getContractFactory("NFTMarketplace");
const nftMarketplace = await NFTMarketplace.deploy();
await nftMarketplace.deployed();
console.log("nftMarketplace deployed to:", nftMarketplace.address);

fs.writeFileSync('./config.js', `
export const marketplaceAddress = "${nftMarketplace.address}"
`)
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Этот скрипт развернет контракт в блокчейн-сети и создаст файл config.js, в котором будет храниться адрес смарт-контракта после его развертывания.

Сначала протестируем его в локальной сети, а затем развернем в тестовой сети Mumbai.

Чтобы развернуть локальную сеть, откройте терминал и выполните следующую команду:

npx hardhat node

Создастся локальная сеть с 20 аккаунтами.

Теперь, сохраняя узел запущенным, откройте отдельное окно терминала для развертывания контракта.

В отдельном окне выполните следующую команду:

npx hardhat run scripts/deploy.js --network localhost

Когда разворачивание будет завершено, CLI выведет адрес развернутого контракта:

Вы также должны увидеть файл config.js, заполненный адресом этого смарт-контракта.

Импорт аккаунтов в MetaMask

Вы можете импортировать аккаунты, созданные узлом, в свой кошелек Metamask, чтобы опробовать их в приложении.

Каждый из этих аккаунтов пополнен на 10000 ETH.

Чтобы импортировать один из этих аккаунтов, переключите сначала сеть вашего кошелька MetaMask на Localhost 8545.

Затем в MetaMask нажмите на Import Account в меню аккаунтов:

Скопируйте и вставьте один из приватных ключей (Private Keys), выведенных CLI, и нажмите Import. После импорта аккаунта вы должны увидеть в нем определенное количество Eth:

Рекомендую проделать эту процедуру с 2 или 3 аккаунтами, чтобы протестировать различные функции взаимодействия между пользователями.

Запуск приложения

Теперь наконец можно протестировать приложение!

Чтобы запустить его, выполните следующую команду в CLI:

npm run dev

Чтобы все проверить, попробуйте выставить токен на продажу, а затем переключиться на другой аккаунт и приобрести его.

Развертывание в Polygon

Теперь, когда проект запущен и протестирован локально, развернем его в Polygon. Начнем с развертывания в Mumbai, тестовой сети Polygon.

Первое, что вам нужно будет сделать, это сохранить один из своих приватных ключей из кошелька в качестве переменной среды.

Чтобы получить приватный ключ, можете использовать один из приватных ключей, предоставленных Hardhat, или экспортировать их непосредственно из MetaMask.

Если вы работаете на Mac, можете установить переменную среды из командной строки следующим образом (не забудьте запустить скрипт развертывания из этого же терминала и сессии):

export privateKey="your-private-key"

Приватные ключи никогда и ни при каких обстоятельствах нельзя распространять публично. Приватный ключ не рекомендуется жестко кодировать в файле. Если вы все же решили это сделать, убедитесь, что используете кошелек для тестирования, и ни при каких обстоятельствах не размещайте файл с приватным ключом в системе управления версиями исходного кода и не выкладывайте его в открытый доступ.

Конфигурирование сети

Теперь нам нужно переключиться с локальной тестовой сети на тестовую сеть Mumbai.

Для этого следует создать и настроить конфигурацию сети.

Сначала откройте MetaMask и нажмите на Settings (Настройки).

Далее нажмите на Networks (Сети), а затем Add Network (Добавить сеть):

На этом этапе добавьте конфигурации для тестовой сети Mumbai:

  • Network Name (имя сети): Mumbai TestNet.
  • New RPC URL (новый URL RPC): https://rpc-mumbai.maticvigil.com.
  • Chain ID (идентификатор цепи): 80001.
  • Currency Symbol (символ валюты): Matic.

Сохраните эти настройки, после чего сможете переключиться на новую сеть и использовать ее!

Наконец, для взаимодействия с приложениями вам понадобятся токены тестовой сети Matic.

Чтобы получить их, можете посетить “кран” этой сети Matic Faucet и ввести адреса кошельков, с которых вы хотели бы запросить токены.

Развертывание в сети Matic/Polygon

Теперь, когда у вас есть токены Matic, вы можете провести развертывание в сети Polygon!

Для начала убедитесь, что на адрес, связанный с приватным ключом, с помощью которого вы развертываете свой контракт, поступили токены Matic для оплаты газовых сборов за транзакцию.

Также не забудьте раскомментировать конфигурацию mumbai в hardhat.config.js:

mumbai: {
url: "https://rpc-mumbai.maticvigil.com",
accounts: [process.env.privateKey]
}

Для развертывания в Matic выполните следующую команду:

npx hardhat run scripts/deploy.js --network mumbai

Если у вас возникла ошибка при развертывании, возможно, причина в перегрузке публичного RPC (сервиса вызова удаленных процедур). В производственной среде рекомендуется использовать провайдер RPC, например Infura, Alchemy, Quicknode и Figment DataHub.

После развертывания контрактов обновите вызов функции loadNFTs в pages/index.js, чтобы включить новую конечную точку RPC:

/* pages/index.js */

/* старый провайдер */
const provider = new ethers.providers.JsonRpcProvider()

/* новый провайдер */
const provider = new ethers.providers.JsonRpcProvider("https://rpc-mumbai.maticvigil.com")

Теперь вы можете обновить адреса контрактов в вашем проекте и провести тесты в новой сети!

npm run dev

Если вы столкнулись с ошибкой, причина может заключаться в неверном адресе контракта, выводимого в консоль Hardhat, из-за бага, с которым я недавно столкнулся. Здесь можно найти правильные адреса контрактов. Нужно вставить адрес, с которого были развернуты контракты, чтобы увидеть последние транзакции и получить адреса контрактов из данных транзакций.

Развертывание в Mainnet

Для развертывания в основной сети Matic/Polygon можете использовать те же шаги, которые выполняли для тестовой сети Mumbai.

Основное отличие заключается в том, что вам нужно будет применить конечную точку для Matic, а также импортировать сеть в ваш кошелек MetaMask, как указано здесь.

Вот пример обновления, которое требуется для этого в вашем проекте:

/* hardhat.config.js */

/* Д
обавление конфигурации основной сети Matic к существующей конфигурации */
...
matic: {
url: "https://rpc-mainnet.maticvigil.com",
accounts: [privateKey]
}
...

Публичные RPC, подобные приведенному выше, могут иметь ограничения в плане трафика или скорости в зависимости от конкретного случая. Вы можете подписаться на бесплатное использование URL RPC посредством таких сервисов, как Infura, MaticVigil, QuickNode, Alchemy, Chainstack и Ankr.

Пример с Infura:

url: `https://polygon-mainnet.infura.io/v3/${infuraId}`

Чтобы просмотреть окончательный исходный код проекта, посетите этот репозиторий.

Следующие шаги

Поздравляю! Вы развернули нетривиальное приложение в Polygon.

Самое приятное в работе с такими решениями, как Polygon,  —  это то, что не нужно прикладывать дополнительные усилия или изучать что-то новое, чего нельзя сказать о разработке непосредственно в системе Ethereum. Почти все API и инструменты на 2-м уровне и сайдчейнах остаются неизменными, что позволяет применять те же навыки на различных платформах, таких как Polygon.

В качестве следующих шагов я бы предложил перенести запросы, реализованные в приложении, на The Graph. Этот протокол открывает гораздо больше шаблонов доступа к данным, включая, среди прочего, пагинацию, фильтрацию и сортировку, которые необходимы для любого реального приложения.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Edge and Node — DEV Community: How to Build a Full Stack NFT Marketplace — V2 (2022)

Предыдущая статьяПродвинутые методы программирования на JavaScript: сравнение элементов двух массивов
Следующая статьяГенерация видео из текста стала возможной