Функции
- Позволяет пользователю создавать 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 на локальном блокчейне, есть много руководств по развертыванию на реальном блокчейне, так что если вы хотите сделать это, не стесняйтесь попробовать.
Спасибо за прочтение, если у вас есть вопросы, дайте мне знать в комментариях.