Создание простого майнингового DApp с использованием NextJS, Brownie, Solidity и TailwindCSS.

Функции

  • Позволяет пользователю создавать NFT, с изображением и метаданными (свойствами).
  • Хранит и отображает НМТ, которыми владеет пользователь.
  • Сбор платы с каждого монетного двора
  • Темный/светлый режим

Резюме

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

Установка

Для этого вам потребуется установить python, node.js и brownie-eth.

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

После этого откройте терминал и запустите brownie bake react simple-mint, это создаст проект Brownie с использованием ReactJS, но подождите, я сказал, что мы будем использовать NextJS для этого проекта, перейдите в папку simple-mint (проект, который вы только что создали) и удалите папку под названием client. После этого вернитесь в терминал и внутри папки simple-mint запустите npx create-next-app client, эта команда создаст совершенно новый проект NextJS. На данный момент это все, что нам нужно, теперь перейдем к смарт-контрактам!

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

Прежде чем мы начнем программировать смарт-контракт, нам нужно изменить brownie-config.yaml, здесь мы включим необходимые нам зависимости, повторную привязку для компилятора, а также создадим файл .env, чтобы избежать досадных ошибок.

Теперь файл brownie-config.yaml должен выглядеть следующим образом:

# change the build directory to be within react's scope
project_structure:
    build: client/artifacts


dependencies:
    - OpenZeppelin/openzeppelin-contracts@4.5.0


# automatically fetch contract sources from Etherscan
autofetch_sources: True
dotenv: .env


compiler:
    solc:
        version: '0.8.4'
        optimizer:
            enabled: true
            runs: 200
        remappings:
            - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@4.5.0"


networks:
  default: development
  development:

    update_interval: 60
    verify: False

  kovan:

    verify: False
    update_interval: 60


wallets:
  from_key: ${PRIVATE_KEY}


# enable output of development artifacts to load with react
dev_deployment_artifacts: true
Вход в полноэкранный режим Выйти из полноэкранного режима

Для этого dapp нам нужен только один смарт-контракт, контракт очень прост — это расширенная версия контракта ERC-721. В папке contracts создайте файл SimpleMint.sol.

// SPDX-License-Identifier: MIT
// contracts/SimpleMint.sol
pragma solidity ^0.8.4;

// Te will extend/use this open zeppelin smart contract to save time
// if you nee more information about ERC721 checkout the OpenZeppelin docs
// https://docs.openzeppelin.com/contracts/4.x/erc721
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

// This smart contract enabled us to give access control to some functions
// https://docs.openzeppelin.com/contracts/4.x/api/access#Ownable
import "@openzeppelin/contracts/access/Ownable.sol";

contract SimpleMint is ERC721, ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;
    // This is the minting fee users have to pay to mint an NFT
    // on the platform
    uint256 private _fee = 0.0025 ether;

    constructor() ERC721("SimpleMint", "SIMPLE") {}

    function safeMint(string memory uri) public payable {
        // This 'require' ensures the user is paying
        // the minting fee
        require (
            msg.value == _fee,
            "You nee to pay a small fee to mint the NFT."
        );

        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, uri);
    }

    // The following functions are overrides required by Solidity.

    function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
        super._burn(tokenId);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    // This function will return a list of Token URIs
    // given an Ethreum address
    function tokensOf(address minter)
        public
        view
        returns (string[] memory)
    {
        // Here we count how many tokens does the user have 
        uint256 count = 0;

        for(uint256 i = 0; i < _tokenIdCounter.current(); i++) {
            if(ownerOf(i) == minter) {
                count ++;
            }
        }

        // Here we create and populate the tokens with their
        // correspoding Token URI
        string[] memory tokens = new string[](count);
        uint256 index = 0;

        for(uint256 i = 0; i < _tokenIdCounter.current(); i++) {
            if(ownerOf(i) == minter) {
                tokens[index] = tokenURI(i);
                index ++;
            }
        }

        return tokens;
    }

    // This function returns the minting fee to users
    function fee()
        public
        view
        returns (uint256)
    {
        return _fee;
    }

    // This function allows you, **and only you**, to change 
    // the minting fee
    function setFee(uint256 newFee)
        public
        onlyOwner
    {
        _fee = newFee;
    }

    // This function will transfer all the fees collected
    // to the owner
    function withdraw()
        public
        onlyOwner
    {
        (bool success, ) = payable(owner()).call{ value: address(this).balance }("");
        require (success);
    }
}
Войдите в полноэкранный режим Выйдите из полноэкранного режима

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

Тестирование

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

Перейдите в папку tests и откройте файл conftest.py, это код, который будет выполняться перед каждым тестом. Файл должен выглядеть следующим образом:

# tests/conftest.py
import pytest


@pytest.fixture(autouse=True)
def setup(fn_isolation):
    """
    Isolation setup fixture.
    This ensures that each test runs against the same base environment.
    """
    pass


@pytest.fixture(scope="module")
def simple_mint(accounts, SimpleMint):
    """
    Yield a `Contract` object for the SimpleMint contract.
    """
    yield accounts[0].deploy(SimpleMint)

Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь создайте файл test_simple_mint.py в папке tests. Первый тест, который мы напишем, будет проверять, правильно ли развернут контракт.

# tests/test_simple_mint.py
from brownie import Wei

def test_simple_mint_deploy(simple_mint):
    """
    Test if the contract is correctly deployed.
    """
    assert simple_mint.fee() == Wei('0.0025 ether')

Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы создадим тест для проверки того, может ли пользователь чеканить NFT и не может ли он чеканить NFT при оплате комиссии.

# tests/test_simple_mint.py
# ...


def test_simple_mint_minting(accounts, simple_mint):
    """
    Test if the contract can mint an NFT, and charge the
    corresponding fee.
    """
    token_uri = 'https://example.mock/uri.json'

    # can't mint, not paying fee
    with reverts():
        simple_mint.safeMint(token_uri, {'from': accounts[1]})

    # can mint, paying fee
    fee = simple_mint.fee()
    simple_mint.safeMint(token_uri, {'from': accounts[1], 'value': fee})

Вход в полноэкранный режим Выход из полноэкранного режима

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

# tests/test_simple_mint.py
# ...


def test_simple_mint_tokens(accounts, simple_mint):
    """
    Test if the contract can mint an NFT, and charge the
    corresponding fee.
    """
    token_uri = 'https://example.mock/uri.json'
    user_one, user_two = accounts[1], accounts[2]
    fee = simple_mint.fee()

    # minting 3 tokens as user one
    simple_mint.safeMint(token_uri, {'from': user_one, 'value': fee})
    simple_mint.safeMint(token_uri, {'from': user_one, 'value': fee})
    simple_mint.safeMint(token_uri, {'from': user_one, 'value': fee})

    # minting 2 tokens as user two
    simple_mint.safeMint(token_uri, {'from': user_two, 'value': fee})
    simple_mint.safeMint(token_uri, {'from': user_two, 'value': fee})


    user_one_tokens = simple_mint.tokensOf(user_one.address)
    assert len(user_one_tokens) == 3

    # here we assert that the owner of the token is the correct one
    print("--- user one's tokens")
    for token_uri, token_id in user_one_tokens:
        assert simple_mint.ownerOf(token_id) == user_one.address
        print(token_uri, token_id)

    user_two_tokens = simple_mint.tokensOf(user_two.address)
    assert len(user_two_tokens) == 2

    # here we assert that the owner of the token is the correct one
    print("--- user two's tokens")
    for token_uri, token_id in user_two_tokens:
        assert simple_mint.ownerOf(token_id) == user_two.address
        print(token_uri, token_id)

Вход в полноэкранный режим Выход из полноэкранного режима

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

# tests/test_simple_mint.py
# ...


def test_simple_mint_fees(accounts, simple_mint):
    """
    Test if the owner, and the owner only, can change the minting fee.
    """

    fee = simple_mint.fee()
    assert simple_mint.fee() == Wei('0.0025 ether')

    # another user cannot change the minting fee
    with reverts():
        simple_mint.setFee(Wei('0.5 ether'), {'from': accounts[1]})

    # the owner can change the minting fee
    new_fee = Wei('0.0025 ether')
    simple_mint.setFee(new_fee, {'from': accounts[0]})
    assert simple_mint.fee() == new_fee

Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, мы протестируем функцию вывода средств и проверим, правильно ли мы получили сумму собранных комиссий.

# tests/test_simple_mint.py
# ...

def test_simple_withdraw(accounts, simple_mint):
    """
    Test if the owner, and the owner only, can withdraw all the
    collected fees.
    """
    fee = Wei('0.5 ether')
    simple_mint.setFee(fee, {'from': accounts[0]})
    initial_balance = accounts[0].balance()
    print(f'Intial balance: {initial_balance}')

    # here we will mint 10 tokens, at a 0.5 ETH fee, this will cost 5 ETH
    # to account one, so the collected fees should amount to 5 ETH
    token_uri = 'https://example.mock/uri.json'
    for i in range(10):
        print(f'mint {i}/10')
        simple_mint.safeMint(
            token_uri, {'from': accounts[1], 'value': fee})

    simple_mint.withdraw({'from': accounts[0]})

    # the owner new balance should be:
    # initial_balance + 5 ETH
    new_balance = accounts[0].balance()
    print(f'New balance: {new_balance}')
    assert initial_balance + Wei('5 ether') == new_balance

Войти в полноэкранный режим Выход из полноэкранного режима

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

Для запуска тестов откройте ganache, как только ganache запустит локальный блокчейн, вы можете вернуться в терминал/консоль и выполнить следующую команду brownie test, это запустит все тесты, которые мы написали (вы также можете выполнить brownie test -s для просмотра отпечатков и более подробной информации по каждому тесту).

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

Front-end

Теперь вернитесь в терминал и перейдите в папку client, нам нужно установить некоторые зависимости, посмотрите руководство по установке TailwindCSS с NextJS, после этого нам нужно установить пакет для взаимодействия с блокчейном из NextJS, запустите npm install web3modal ethers Axios ipfs-http-client, после установки мы можем начать создание нашего front-end.

Компоненты

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

Компонент загрузки

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

// components/Loading/index.js
export default function Loading({ text }) {
  return (
    <div className="overflow-none fixed top-0 left-0 flex h-screen w-screen items-center justify-center bg-black bg-opacity-50">
      <div className="flex items-center text-white">
        <svg
          className="-ml-1 mr-3 h-5 w-5 animate-spin text-white"
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
        >
          <circle
            className="opacity-25"
            cx="12"
            cy="12"
            r="10"
            stroke="currentColor"
            strokeWidth="4"
          ></circle>
          <path
            className="opacity-75"
            fill="currentColor"
            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
          ></path>
        </svg>
        {text}...
      </div>
    </div>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Навбар

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

// components/Navbar/index.js
import Image from 'next/image'
import Link from 'next/link'

// This returns a **readable** wallet address
const formatAddress = (address) =>
  address.slice(0, 5) + '...' + address.slice(38)

export default function Navbar({ address, connectWallet, theme, setTheme }) {
  return (
    <div className="py-6 md:px-6">
      <div className="flex items-center justify-center border-b border-zinc-100 px-3 pb-6 dark:border-zinc-600 sm:justify-between">
        {/* logo */}
        <div className="hidden cursor-pointer sm:inline-flex">
          <Link href="/">
            <a>
              <Image src="/logo.png" width={90} height={78} />
            </a>
          </Link>
        </div>

        {/* connect button */}
        {!address && (
          <div className="flex items-center">
            <button
              onClick={connectWallet}
              className="cursor-pointer rounded-md bg-green-400 py-2 px-3 text-white hover:bg-green-500"
            >
              Connect
            </button>
          </div>
        )}

        {/* navigation & user's address */}
        {address && (
          <div className="flex items-center space-x-3">
            <Link href="/">
              <a className="hover:underline">My NFTs</a>
            </Link>
            <Link href="/create">
              <a className="hover:underline">Create</a>
            </Link>
            <p className="rounded-md bg-green-400 py-2 px-3 text-white">
              {formatAddress(address)}
            </p>
          </div>
        )}
      </div>

      {/* theme toggler */}
      <div className="fixed -bottom-1 -right-1 rounded-md border border-zinc-100 bg-white bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-600">
        {theme === 'light' && (
          <svg
            cursor="pointer"
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={2}
            onClick={() => setTheme('dark')}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
            />
          </svg>
        )}
        {theme === 'dark' && (
          <svg
            cursor="pointer"
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={2}
            onClick={() => setTheme('light')}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
            />
          </svg>
        )}
      </div>
    </div>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Нет кошелька

Это простой компонент, предлагающий пользователю подключить свой кошелек.

// components/NoWallet/index.js

export default function NoWallet() {
  return (
    <div className="flex h-screen w-screen items-center justify-center md:h-[65vh]">
      <p className="font-3xl font-bold text-zinc-400 dark:text-zinc-200">
        Connect your wallet to access the application
      </p>
    </div>
  )
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Без минтов

Нам также нужен компонент no mints, чтобы показать пользователю, что он не создал NFT с этим адресом.

// components/NoMints/index.js
import Link from 'next/link'

export default function NoMints() {
  return (
    <div className="flex h-screen w-full items-center justify-center md:h-[65vh]">
      <p className="font-3xl font-bold text-zinc-400 dark:text-zinc-200">
        Looks like you haven't created any NFT's yet,{' '}
        <Link href="/create">
          <span className="cursor-pointer text-green-500 hover:underline">
            creaate one now
          </span>
        </Link>
        .
      </p>
    </div>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Карточка НМТ

Этот компонент будет отображать изображение и название НМТ, которым владеет/минтирует пользователь, а также будет содержать ссылку на страницу с подробной информацией об этом НМТ.

// components/NFTCard/index.js
import Link from 'next/link'
import Image from 'next/image'

export default function NFTCard({ data }) {
  return (
    <div className="max-w-96 group relative h-72 cursor-pointer rounded-md duration-100 ease-in-out hover:scale-105 sm:w-72">
      <div className="max-w-96 relative h-72 rounded-md sm:w-72">
        <Image
          className="rounded-md"
          layout="fill"
          objectFit="cover"
          quality={100}
          src={data.metadata.image}
          alt="text"
        />
        <div className="none absolute bottom-0 flex hidden h-12 w-full items-center rounded-b-md bg-zinc-800 px-3 font-bold text-white ease-in-out group-hover:flex dark:bg-white dark:text-zinc-800 ">
          <Link href={`/details/${data.tokenId}`}>
            <a className="hover:text-green-300 hover:underline">
              {data.metadata.name}
            </a>
          </Link>
        </div>
      </div>
    </div>
  )
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Подробности

Это компонент-обертка, выпадающий список для отображения подробностей о НМТ. Сами детали могут быть любыми по вашему желанию, а компонент может отображать их в виде сетки или нет.

// components/Details/index.js
export default function Details({ summary, isGrid, children }) {
  return (
    <details className="group border border-zinc-100 p-3 hover:cursor-pointer dark:border-zinc-600">
      <summary className="font-xl flex w-full list-none items-center justify-between font-bold">
        {/* The title of the drop down */}
        <span className="group-hover:underline">{summary}</span>

        <div className="icon">
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth="2"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M19 9l-7 7-7-7"
            />
          </svg>
        </div>
      </summary>

      {/* if its children should be in a grid */}
      {isGrid && (
        <div className="grid grid-flow-row grid-cols-2 gap-4 pt-3 md:grid-cols-3 xl:grid-cols-4">
          {children}
        </div>
      )}

      {/* else */}
      {!isGrid && (
        <div className="pt-3 text-zinc-800 dark:text-zinc-50">{children}</div>
      )}
    </details>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Плитка деталей

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

// components/DetailTile/index.js
export default function DetailTile({ title, value }) {
  return (
    <div className="flex h-32 w-full flex-col items-center justify-center rounded-md border border-zinc-100 p-3 dark:border-zinc-600">
      <p className="text-3xl text-zinc-800 dark:text-zinc-50">{value}</p>
      <p className="text-xl font-bold text-green-500">{title}</p>
    </div>
  )
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Форма добавления атрибутов

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

// components/AddAttributes/index.js
import { useState } from 'react'

// A function to capitalize text
// Ex. capitalize("soME TeXT") => "Some Text"
const capitalize = (text) =>
  text
    .trim()
    .toLowerCase()
    .split(' ')
    .map((word) => word[0].toUpperCase() + word.slice(1))
    .join(' ')

export default function AddAttributes({ addAttribute }) {
  // ERC-721 metadata attributes
  // {
  //    "display_type": "boost_number",
  //    "trait_type": "Aqua Power",
  //    "value": 40
  // }

  const [displayType, setDisplayType] = useState('')
  const [traitType, setTraitType] = useState('text')
  const [value, setValue] = useState('')

  function handleAddAttribute(e) {
    e.preventDefault()

    // if one field is empty return
    if (!displayType || !traitType || !value) {
      return
    }

    let data = { displayType: capitalize(displayType), traitType, value }
    switch (data.traitType) {
      case 'text': {
        data.value = capitalize(data.value)
        break
      }
      case 'boost_percentage': {
        data.value = Number(data.value) + '%'
        break
      }
      case 'boost_number':
      case 'number': {
        data.value = Number(data.value)
        break
      }
    }

    addAttribute(data)

    // reset all fields
    setDisplayType('')
    setTraitType('text')
    setValue('')
  }

  return (
    <form className="flex h-16 w-full items-center space-x-3">
      <div className="flex flex-grow flex-col">
        <label
          htmlFor="name"
          className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
        >
          Name
        </label>
        <input
          className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500"
          id="name"
          type="text"
          value={displayType}
          placeholder="Ex. Power"
          onChange={(e) => setDisplayType(e.target.value)}
        />
      </div>
      <div className="flex flex-grow flex-col">
        <label
          htmlFor="traitType"
          className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
        >
          Trait Type
        </label>
        <select
          className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500"
          name="traitType"
          value={traitType}
          onChange={(e) => setTraitType(e.target.value)}
        >
          <option value="text">Text</option>
          <option value="boost_percentage">Boost Percentage</option>
          <option value="boost_number">Boost Number</option>
          <option value="number">Number</option>
        </select>
      </div>
      <div className="flex flex-grow flex-col">
        <label
          htmlFor="value"
          className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
        >
          Value
        </label>
        <input
          id="value"
          className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500"
          type="text"
          value={value}
          placeholder="Ex. 25"
          onChange={(e) => setValue(e.target.value)}
        />
      </div>
      <div className="flex w-12 flex-col">
        <button
          className="mt-5 flex h-12 w-12 items-center justify-center rounded-md bg-green-400 text-white hover:bg-green-500"
          type="submit"
          onClick={handleAddAttribute}
        >
          <svg
            xmlns="http://www.w3.org/2000/svg"
            className="h-6 w-6"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
            strokeWidth={4}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M12 6v6m0 0v6m0-6h6m-6 0H6"
            />
          </svg>
        </button>
      </div>
    </form>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Таблица атрибутов

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

// components/AttributesTable/index.js
export default function AttributesTable({ attributes, removeAttribute }) {
  // If there are no attributes, don't show anything
  if (attributes.length === 0) {
    return null
  }

  return (
    <table className="w-full border border-zinc-100 dark:border-zinc-600">
      <thead>
        <tr>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Name
          </th>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Display Type
          </th>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Value
          </th>
          <th className="border border-zinc-100 p-3 text-sm uppercase text-zinc-500 dark:border-zinc-600 dark:text-zinc-300">
            Remove
          </th>
        </tr>
      </thead>
      <tbody>
        {attributes.map((attribute, i) => (
          <tr key={i}>
            <td className="border border-zinc-100 text-center dark:border-zinc-600">
              {attribute.displayType}
            </td>
            <td className="border border-zinc-100 text-center lowercase text-zinc-400 dark:border-zinc-600 dark:text-zinc-300">
              {attribute.traitType}
            </td>
            <td className="border border-zinc-100 text-center dark:border-zinc-600">
              {attribute.value}
            </td>
            <td className="border border-zinc-100 text-center dark:border-zinc-600">
              <button
                className="my-1 rounded-md px-2 py-1 font-semibold text-red-500 hover:bg-red-500 hover:text-white"
                onClick={() => removeAttribute(i)}
              >
                REMOVE
              </button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

Страницы

Здесь мы будем управлять нашим контекстом, нашими смарт-контрактами и всей логикой, которая соединяется с чем-то еще. Я не ожидаю, что вы будете экспертом в Context API, но я бы рекомендовал вам взглянуть на то, как это работает, если вы еще этого не сделали.

Сначала мы создадим Web3 Context, который будет содержать всю информацию о смарт-контрактах и подключенном кошельке. Идем дальше и внутри папки client создаем файл store/web3Context.js.

import { createContext } from 'react'

export default createContext({
  simpleMint: null,
  signer: null,
  address: null,
})
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы создадим Theme Context, он может быть немного сложнее, но он заботится только о теме приложения (светлая или темная).

export const getInitialTheme = () => {
  if (typeof window !== 'undefined' && window.localStorage) {
    const storedPrefs = window.localStorage.getItem('color-theme')
    if (typeof storedPrefs === 'string') {
      return storedPrefs
    }

    const userMedia = window.matchMedia('(prefers-color-scheme: dark)')
    if (userMedia.matches) {
      return 'dark'
    }
  }

  return 'light' // light theme as the default;
}

export const rawSetTheme = (rawTheme) => {
  const root = window.document.documentElement
  const isDark = rawTheme === 'dark'

  root.classList.remove(isDark ? 'light' : 'dark')
  root.classList.add(rawTheme)

  localStorage.setItem('color-theme', rawTheme)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

страница _app

Это компонент-обертка, здесь мы разместим наши контексты и подключим их к нашему web3-приложению. Эта страница отображает компонент NoWallet, если к приложению не подключен кошелек.

Он делает много вещей, поэтому не спешите читать комментарии.

import { useState, useEffect } from 'react'
import Web3Modal from 'web3modal'
import { ethers } from 'ethers'

// components
import Navbar from '../components/Navbar'
import NoWallet from '../components/NoWallet'

// store and context
import { getInitialTheme, rawSetTheme } from '../store/themeContext'
import Web3Context, { getNetworkName } from '../store/web3Context'

// styles
import '../styles/globals.css'

// smart-contracts
import SimpleMint from '../artifacts/contracts/SimpleMint.json'

function App({ Component, pageProps }) {
  // app theme
  const [theme, setTheme] = useState(getInitialTheme)

  // web3 dapp state
  const [signer, setSigner] = useState(null)
  const [address, setAddress] = useState(null)
  const [simpleMint, setSimpleMint] = useState(null)

  // sets the theme on change
  useEffect(() => {
    rawSetTheme(theme)
  }, [theme])

  async function connectWallet() {
    const web3Modal = new Web3Modal()
    const connection = await web3Modal.connect()
    const provider = new ethers.providers.Web3Provider(connection)
    const signer = provider.getSigner()
    const address = await signer.getAddress()
    const { chainId } = await provider.getNetwork()
    const chainName = getNetworkName(chainId)

    // this deployed simple mint smartcontract address 
    /// *** REPLACE THIS ***
    const simpleMintAddress = '0x854b699d119c5f89681c96d282098e4420eDa135'

    const simpleMintContract = new ethers.Contract(
      simpleMintAddress,
      SimpleMint.abi,
      signer
    )

    setSigner(signer)
    setAddress(address)
    setSimpleMint(simpleMintContract)
  }

  return (
    <div>
      <Navbar
        address={address}
        connectWallet={connectWallet}
        theme={theme}
        setTheme={setTheme}
      />
      {address && (
        <Web3Context.Provider value={{ signer, address, simpleMint }}>
          <Component {...pageProps} />
        </Web3Context.Provider>
      )}
      {!address && <NoWallet />}
    </div>
  )
}

export default App
Вход в полноэкранный режим Выход из полноэкранного режима

Индексная страница

Это главная страница, здесь мы отображаем NFT, которыми владеет пользователь, и если пользователь еще не майнил NFT, мы отобразим компонент NoMints.

// pages/index.js
import { useContext, useState, useEffect } from 'react'
import axios from 'axios'
import Head from 'next/head'

// store
import Web3Context from '../store/web3Context'

// components
import NFTCard from '../components/NFTCard'
import NoMints from '../components/NoMints'

export default function Home() {
  const { simpleMint, address } = useContext(Web3Context)
  const [nfts, setNfts] = useState([])

  // once connected a wallet load the nfts
  useEffect(() => {
    if (simpleMint && address) {
      loadNfts()
    }
  }, [simpleMint, address])

  async function loadNfts() {
    let nfts = await simpleMint.tokensOf(address)

    // tokensOf returns a Token ID and a Token URI
    // we need to retrive and parse that data
    nfts = await Promise.all(
      nfts.map(async (nft) => {
        // token as returned from the smart-contract
        let [metadata, tokenId] = nft

        // parsing the token id
        tokenId = tokenId.toString()
        // fetching the metadata
        metadata = await axios.get(metadata).then((res) => res.data)

        return { metadata, tokenId }
      })
    )

    setNfts(nfts)
  }

  return (
    <div>
      <Head>
        <title>Simple Mint</title>
        <meta name="description" content="NFT minting Dapp" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <div className="px-3 md:px-6">
        <h1 className="text-3xl font-bold">NFTs</h1>
        {nfts.length == 0 && <NoMints />}
        {nfts.length != 0 && (
          <div className="sm: grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 p-3">
            {nfts.map((nft, i) => (
              <NFTCard key={i} data={nft} />
            ))}
          </div>
        )}
      </div>
    </div>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Страница создания

Эта страница предоставляет пользователю отличный пользовательский интерфейс для создания NFT без кода.

// pages/create.js
import { useContext, useState } from 'react'
import { create as ipfsHttpClient } from 'ipfs-http-client'
import { useRouter } from 'next/router'
import Head from 'next/head'
import Image from 'next/image'

// components
import AddAttributes from '../components/AddAttributes'
import AttributesTable from '../components/AttributesTable'
import Loading from '../components/Loading'

// store
import Web3Context from '../store/web3Context'

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

export default function Create() {
  const { simpleMint, address } = useContext(Web3Context)
  // we will use the router to change the view after creating the NFT
  const router = useRouter()

  const [name, setName] = useState('')
  const [description, setDescription] = useState('')
  const [attributes, setAttributes] = useState([])
  const [imageUrl, setImageUrl] = useState(null)
  const [uploading, setUploading] = useState(false)
  const [loading, setLoading] = useState(false)

  // simple function to remove an attribute
  function removeAttribute(index) {
    let newAttributes = []
    for (let i = 0; i < attributes.length; i++) {
      if (i == index) {
        continue
      }

      newAttributes.push(attributes[i])
    }
    setAttributes(newAttributes)
  }

  async function uploadImage(event) {
    try {
      setUploading(true)

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('You must select an image to upload.')
      }

      const file = event.target.files[0]
      const added = await client.add(file)
      const url = `https://ipfs.infura.io/ipfs/${added.path}`
      setImageUrl(url)
    } catch (error) {
      alert(error.message)
    } finally {
      setUploading(false)
    }
  }

  async function createNft() {
    // all data is required to create an NFT
    if (!name && !description && attributes.length === 0 && !imageUrl) {
      return
    }

    // collect all data into an object
    const data = {
      name,
      image: imageUrl,
      description,
      attributes,
    }

    try {
      setLoading(true)
      // we parse the data as JSON before uploading it to IPFS
      const added = await client.add(JSON.stringify(data))
      const url = `https://ipfs.infura.io/ipfs/${added.path}`

      // get the minting fee to mint the NFT
      let fee = await simpleMint.fee()
      fee = fee.toString()

      // wait till the transaction is confirmed
      const tx = await simpleMint.safeMint(url, { value: fee })
      await tx.wait()

      router.push('/')
    } catch (error) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div>
      <Head>
        <title>Create | Simple Mint</title>
        <meta name="description" content="NFT minting Dapp" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      {loading && <Loading text="Processing" />}

      <div className="px-3 md:px-6">
        <h1 className="text-3xl font-bold">Create NFT</h1>
        <div className="flex flex-col space-y-6 py-12">
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="name"
              className="mb-1 text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Image
            </label>
            <input
              className="
                block w-full cursor-pointer text-sm
                text-slate-500 file:mr-4 file:rounded-full
                file:border-0 file:bg-green-400
                file:py-2 file:px-4
                file:text-sm file:font-semibold
                file:text-white
                hover:file:bg-green-500
              "
              type="file"
              id="single"
              accept="image/*"
              onChange={uploadImage}
              disabled={uploading}
            />
          </div>
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="name"
              className="mb-1 text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Loaded Image
            </label>
            {uploading && (
              <div className="max-w-96 flex h-72 w-full animate-pulse items-center justify-center rounded-md bg-zinc-400">
                <p>Loading...</p>
              </div>
            )}
            {!uploading && imageUrl && (
              <div className="max-w-96 relative h-72 rounded-md sm:w-72">
                <Image
                  className="rounded-md"
                  layout="fill"
                  objectFit="cover"
                  quality={100}
                  src={imageUrl}
                  alt="text"
                />
              </div>
            )}
          </div>
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="name"
              className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Name
            </label>
            <input
              className="placeholder-text-xl h-12 rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500 dark:focus:border-green-400"
              id="name"
              type="text"
              value={name}
              placeholder="Ex. Power"
              onChange={(e) => setName(e.target.value)}
            />
          </div>
          <div className="flex flex-grow flex-col">
            <label
              htmlFor="description"
              className="text-sm uppercase text-zinc-500 dark:text-zinc-300"
            >
              Description
            </label>
            <textarea
              className="placeholder-text-xl rounded-md border-2 border-zinc-100 bg-zinc-100 px-2 py-1 outline-none focus:border-green-400 dark:border-zinc-500 dark:bg-zinc-500 dark:focus:border-green-400"
              id="description"
              type="text"
              value={description}
              placeholder="Ex. Lorem ipsum dolor sit amet."
              onChange={(e) => setDescription(e.target.value)}
            />
          </div>
          <div className="flex flex-grow flex-col">
            <AddAttributes
              addAttribute={(data) => setAttributes((prev) => [...prev, data])}
            />
          </div>
          <div className="flex-grow">
            <p className="pb-1 text-sm uppercase text-zinc-500 dark:text-zinc-300">
              Attributes
            </p>
            <AttributesTable
              attributes={attributes}
              removeAttribute={removeAttribute}
            />
          </div>
          <div>
            <button
              onClick={createNft}
              className="w-full cursor-pointer rounded-md bg-green-400 py-2 px-3 text-white hover:bg-green-500"
            >
              Create
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

Вход в полноэкранный режим Выйти из полноэкранного режима

Страница подробностей

Эта страница объясняет сама себя, она извлекает и отображает данные из определенного токена. У этой страницы есть параметр, ID токена, более подробную информацию смотрите в следующих документах.

// pages/details/[tokenId].js
import { useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import Image from 'next/image'
import axios from 'axios'

// components
import Details from '../../components/Details'
import DetailTile from '../../components/DetailTile'
import Loading from '../../components/Loading'

// store
import Web3Context from '../../store/web3Context'

export default function TokenDetails() {
  const { simpleMint, address } = useContext(Web3Context)

  const router = useRouter()
  const { tokenId } = router.query

  const [nft, setNft] = useState(null)
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    if (simpleMint && address) {
      loadNft()
    }
  }, [simpleMint, address])

  async function loadNft() {
    try {
      setLoading(true)
      const tokenURI = await simpleMint.tokenURI(tokenId)
      const metadata = await axios.get(tokenURI).then((res) => res.data)

      setNft({ metadata, tokenId })
      setLoading(false)
    } catch (err) {
      window.alert(err)
    }
  }

  return (
    <div>
      <Head>
        <title>Token: #{tokenId} | Simple Mint</title>
        <meta name="description" content="NFT minting Dapp" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      {loading && <Loading text="Loading" />}

      {nft && (
        <div className="px-3 md:px-6">
          <h1 className="text-3xl font-bold">Token: #{nft.tokenId}</h1>
          <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
            <div className="group relative h-96 rounded-md p-3 md:max-w-[100%]">
              <div className="relative h-96 rounded-md">
                <Image
                  className="rounded-md"
                  layout="fill"
                  objectFit="cover"
                  quality={100}
                  src={nft.metadata.image}
                  alt="text"
                />
              </div>
            </div>

            <div className="p-3">
              {/* NFT Name */}
              <Details summary="Name">
                <p>{nft.metadata.name}</p>
              </Details>

              {/* NFT Description */}
              <Details summary="Description">
                <p>{nft.metadata.description}</p>
              </Details>

              {/* NFT Properties, if there are no properties don't display */}
              {nft.metadata.attributes.filter(
                (attr) => attr.traitType == 'text' || attr.traitType == 'number'
              ).length !== 0 && (
                <Details summary="Properties" isGrid>
                  {nft.metadata.attributes
                    .filter(
                      (attr) =>
                        attr.traitType == 'text' || attr.traitType == 'number'
                    )
                    .map((attr) => (
                      <DetailTile title={attr.displayType} value={attr.value} />
                    ))}
                </Details>
              )}

              {/* NFT Boosts, if there are no boosts don't display */}
              {nft.metadata.attributes.filter(
                (attr) =>
                  attr.traitType == 'boost_percentage' ||
                  attr.traitType == 'boost_number'
              ).length !== 0 && (
                <Details summary="Boosts" isGrid>
                  {nft.metadata.attributes
                    .filter(
                      (attr) =>
                        attr.traitType == 'boost_percentage' ||
                        attr.traitType == 'boost_number'
                    )
                    .map((attr) => (
                      <DetailTile title={attr.displayType} value={attr.value} />
                    ))}
                </Details>
              )}
            </div>
          </div>
        </div>
      )}
    </div>
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вот и все, что нам нужно было сделать с фронт-эндом.

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

Чтобы развернуть контракт из терминала/консоли, откройте файл scripts/deploy.js, этот файл будет запущен Brownie ETH для развертывания контракта.

from brownie import SimpleMint, accounts, network

def main():
    # requires brownie account to have been created
    if network.show_active()=='development':
        # add these accounts to metamask by importing private key
        owner = accounts[0]
        SimpleMint.deploy({'from':accounts[0]})

Войти в полноэкранный режим Выйти из полноэкранного режима

После этого вы можете запустить команду brownie run deploy для развертывания смарт-контракта Simple Mint, не забудьте запустить Ganache перед выполнением этой команды и изменить адрес смарт-контракта в файле _app.js во front-end.

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

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

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *