В первой части этого руководства мы написали смарт-контракт Ethereum для проекта NFT под названием ReplBots – фотографии профилей роботов со случайно генерируемыми цветами и аксессуарами. Этот контракт позволил пользователям создавать НФТ ReplBot на блокчейне и сформировал логическую часть нашего децентрализованного приложения НФТ. Во второй части урока мы создадим веб-фронтенд для нашего смарт-контракта, предоставив пользователям возможность просматривать свои NFT.
К концу этого урока вы будете знать основы взаимодействия с кошельками и смарт-контрактами Ethereum на JavaScript и создания web3-приложений.
Начало работы
Откройте repl, который вы создали в первой части этого руководства, или клонируйте наш.
Если вы еще не сделали этого, установите Metamask в свой браузер.
После загрузки repl создайте новый каталог frontend
. Эта директория будет содержать HTML, JavaScript и файлы изображений, используемые для нашего фронтенда. Внутри этого каталога создайте второй каталог с именем svg
. Скачайте эту ZIP-папку, содержащую наши художественные активы NFT, распакуйте ее и загрузите ее содержимое в каталог svg
, который вы только что создали.
Внутри основной директории frontend
создайте следующие файлы:
Прежде чем мы начнем заполнять эти файлы, нам нужно иметь экземпляр контракта ReplBots, к которому будет подключаться наш фронтенд. Давайте развернем новую копию нашего контракта в Replit Testnet, используя веб-интерфейс нашей Solidity repl.
Для начала запустите свой repl. После установки всех зависимостей вы должны увидеть веб-интерфейс Replit Ethereum в браузере вашего repl. Он выглядит следующим образом:
Подключите свой кошелек MetaMask к веб-интерфейсу и переключитесь на Replit Testnet. Если на вашем балансе 0 ETH, нажмите на ссылку “Получить 1 ETH для тестирования”. Подождите, пока 1 ETH не появится в балансе вашего кошелька в правом верхнем углу страницы.
Теперь вы можете развернуть свои контракты. Выберите “ReplBots” из выпадающего списка и нажмите Deploy. Одобрите появившееся всплывающее окно MetaMask.
Когда контракт будет развернут, он отобразится в виде расширяемого поля. Если вы развернете развернутый контракт, внизу вы увидите значение, начинающееся с 0x
: адрес контракта. Нажмите на этот адрес, чтобы скопировать его в буфер обмена. Затем вставьте его в безопасное место, например, в нижнюю часть файла README.md
.
Рядом с адресом контракта вы должны увидеть кнопку с надписью “Copy ABI”. Нажмите на эту кнопку, чтобы скопировать ABI (Application Binary Interface) контракта в буфер обмена и вставить его в то же место, куда вы поместили адрес контракта. Вы должны увидеть большой объект JSON.
Обе эти части данных понадобятся нам позже для взаимодействия с нашим контрактом.
Настройка фронтенда
Откройте frontend/index.html
и заполните его следующей разметкой:
<!DOCTYPE html>
<html>
<head>
<title>ReplBot NFTs</title>
<link href="/style.css" rel="stylesheet">
<head>
<body>
<button id="mint" class="button">Mint ReplBot</button><br>
<form id="breed">
Parent 1: <input name="parentOneId" type="text"></input><br>
Parent 2: <input name="parentTwoId" type="text"></input><br>
<input type="submit" class="button" value="Breed new ReplBot!"></input>
</form>
<div id="bots">
<script src="./app.js"></script>
</body>
</html>
В этом файле мы создали следующее:
- Элемент
button
для создания нового ReplBot. - Элемент
form
для создания нового ReplBot из двух родителей. - Элемент
div
для отображения принадлежащих пользователю ReplBot’ов.
Мы также связали нашу таблицу стилей в верхней части файла и наш файл JavaScript в нижней части.
Добавьте следующий CSS в frontend/style.css
:
body {
font-family: monospace;
}
.button {
font-size: 1.1em;
border-width: 2px;
background-color: white;
margin: 1em;
}
form {
border: 4px solid black;
padding: 1em;
display: inline-block;
}
#bots {
display: flex;
flex-wrap: wrap;
}
#bots > svg {
width: 30em;
}
Наконец, добавьте этот скелет кода в frontend/app.js
:
App = {
replbotAddress: "INSERT-CONTRACT-ADDRESS-HERE",
replbotContract: null,
init: async function() {},
}
App.init();
Этот объект App
будет содержать все состояние и функциональность, которые мы будем реализовывать. Вставьте адрес контракта, который вы скопировали ранее, в качестве значения replbotAddress
.
Подключение к блокчейну
Большинство Ethereum dapps используют одну из двух библиотек для взаимодействия с блокчейном: web3.js
или ethers.js
. Библиотека ethers.js
использовалась для интерфейса Solidity Starter от Replit, но мы будем использовать web3.js
для этого руководства.
Мы можем импортировать web3.js
, добавив следующую строку в index.html
, чуть выше строки, где мы импортируем app.js
:
<script src="https://cdn.jsdelivr.net/npm/web3@latest/dist/web3.min.js"></script>
Затем, вернувшись в frontend/app.js
, мы подключимся к блокчейну в функции init
нашего приложения. Добавьте следующий код в тело функции:
init: async function() {
if (window.ethereum) {
await window.ethereum.request({ method: 'eth_requestAccounts' });
window.web3 = new Web3(window.ethereum);
}
},
Этот код будет взаимодействовать с MetaMask и показывать всплывающее окно с предложением пользователю подключить свой кошелек к нашей dapp. Если пользователь согласится, мы создадим объект Web3
, используя данные его счета.
Большинство функций web3.js
являются асинхронными, поэтому мы должны использовать await
для получения их возвращаемых значений. Поскольку await
можно использовать только в асинхронных функциях или в JavaScript верхнего уровня, большинство наших функций также будут асинхронными.
Поскольку мы строим на базе Replit Testnet, нам нужно будет добавить код, который предложит пользователю переключиться на эту сеть. Добавьте следующее определение функции чуть ниже определения init
:
switchToReplitTestnet: function() {
window.ethereum.request({
method: "wallet_addEthereumChain",
params: [
{
chainId: "0x7265706c",
chainName: "Replit Testnet",
rpcUrls: ["https://eth.replit.com"],
iconUrls: [
"https://upload.wikimedia.org/wikipedia/commons/b/b2/Repl.it_logo.svg",
],
nativeCurrency: {
name: "Replit ETH",
symbol: "RΞ",
decimals: 18,
},
},
],
});
},
Этот код предоставляет детали, необходимые MetaMask для подсказки пользователю о переключении сети. Мы вызовем его из нашей функции init
, как показано ниже:
init: async function() {
if (window.ethereum) {
await window.ethereum.request({ method: 'eth_requestAccounts' });
window.web3 = new Web3(window.ethereum);
// NEW CODE BELOW
// Switch networks
App.switchToReplitTestnet();
}
},
Взаимодействие с умным контрактом
Теперь, когда мы можем подключиться к блокчейну, нам нужно получить доступ к нашему контракту ReplBots. Чтобы подключиться к контракту и запустить его функции, нам нужны две вещи: адрес контракта, который показывает нам, где его найти, и его ABI, который говорит нам, какие функции он реализует и каковы их параметры. Мы уже указали адрес, теперь осталось указать ABI.
Создайте новый файл в frontend
под названием replbotABI.js
и добавьте в него следующий код:
const replbotABI =
Найдите данные ABI, которые вы скопировали после развертывания контракта ReplBots, и вставьте их в качестве значения replbotABI
. После этого добавьте следующую строку в frontend/index.html
, чуть ниже строки, где вы импортируете web3
:
<script src="./replbotABI.js"></script>
Теперь мы можем инстанцировать App.replbotContract
как web3.eth.Contract
. Измените вашу функцию init
в frontend/app.js
так, чтобы она соответствовала следующей:
init: async function() {
if (window.ethereum) {
await window.ethereum.request({ method: 'eth_requestAccounts' });
window.web3 = new Web3(window.ethereum);
// Switch networks
App.switchToReplitTestnet();
// NEW CODE BELOW
// Interface with contract
App.replbotContract = new web3.eth.Contract(replbotABI, App.replbotAddress);
}
},
Это все, что нам нужно для подключения к нашему контракту. Теперь мы можем реализовать некоторые из его функций на нашем фронтенде.
Выполнение функций контракта
Мы начнем с реализации некоторых функций в App
для получения информации из контракта. Первая из них, getMyReplBotIds
, вернет список ReplBots в кошельке текущего пользователя. Добавьте следующий код в определение App
внизу.
// view data in contract
getMyReplBotIds: async function() {
// get user's address
const accounts = await web3.eth.getAccounts();
const account = accounts[0];
// get number of ReplBots owned
let balance = await App.replbotContract.methods.balanceOf(account).call();
// get each one's ID
var botIds = [];
for (i = 0; i < balance; i++) {
botIds.push(await App.replbotContract.methods.tokenOfOwnerByIndex(account, i).call());
}
return botIds;
},
В этой функции мы используем web3.eth.getAccounts()
для поиска адреса кошелька пользователя, который мы используем в последующих вызовах balanceOf()
и tokenOfOwnerByIndex()
. Обе эти функции являются частью стандарта ERC-721 NFT (хотя последняя относится к дополнительному расширению, ERC721Enumerable
) и реализованы контрактом OpenZeppelin, от которого наследуется наш контракт ReplBots. Обратите внимание на синтаксис вызова метода контракта с помощью call()
.
Далее, давайте реализуем функцию, которая возвращает подробную информацию о данном ReplBot. Добавьте следующий код ниже определения getMyReplBotIds()
:
getReplBotDetails: async function(tokenId) {
var bot = {};
bot.colors = await App.replbotContract.methods.botColors(tokenId).call();
bot.accessories = await App.replbotContract.methods.botAccessories(tokenId).call();
bot.parentage = await App.replbotContract.methods.botParentage(tokenId).call();
return bot;
},
Этот код довольно прост. Мы объединили все три функции информации о ботах нашего контракта в одну функцию с одним возвращаемым значением.
На данный момент это единственные функции просмотра, о которых нам нужно беспокоиться, поэтому перейдем к функциям, изменяющим состояние. Сначала мы реализуем функцию для mint
. Добавьте следующий код в нижнюю часть определения App
:
// create new NFTs
mintReplBot: async function() {
const accounts = await web3.eth.getAccounts();
const account = accounts[0];
// Mint to own address
App.replbotContract.methods.mint(account).send({from: account});
},
Когда мы вызываем функции, возвращающие информацию без изменения состояния, мы можем использовать .call()
и не указывать адрес вызывающей стороны. Это происходит потому, что внешние вызовы функций представления не требуют затрат газа – они могут выполняться на одном, локальном узле, а не всей сетью. Однако функции, которые изменяют состояние, должны выполняться всей сетью, чтобы достичь консенсуса, а это стоит газа. Поэтому мы используем .send()
вместо .call()
, и должны указать отправителя, которому придется заплатить за газ.
Наша функция для функции breed()
аналогична. Добавьте следующий код чуть ниже вашего определения mintReplBot
:
breedReplBot: async function(parentOneId, parentTwoId) {
const accounts = await web3.eth.getAccounts();
const account = accounts[0];
await App.replbotContract.methods.breed(parentOneId, parentTwoId, account).send({from: account});
},
Просмотр и комбинирование SVG
Выше мы реализовали ключевые функции контракта, включая функции, которые позволят нам получить детали отдельных NFT ReplBot. На данный момент детали этих NFT представляют собой просто списки строк. В этой части руководства мы превратим эти списки в уникальные изображения, используя мощный формат изображений SVG.
Наиболее распространенные файлы изображений, такие как JPEG и PNG, являются растровой графикой – сильно сжатыми представлениями сетки пикселей. SVG, с другой стороны, это векторная графика, созданная из фигур. Об этом можно рассуждать следующим образом: файлы растровой графики содержат инструкции типа “окрасить пиксель в точке (10,20) в красный цвет”, тогда как файлы векторной графики содержат инструкции типа “провести красную линию от точки (10,12) до (20,14)”.
Оба формата изображений имеют свои преимущества и недостатки. Преимуществом SVGs является то, что они форматируются подобно HTML-файлам, поэтому ими довольно просто программно манипулировать на JavaScript. Чтобы увидеть, как SVG выглядит под капотом, переименуйте один из файлов в frontend/svg
из .svg
в .html
.
Как видите, SVG состоит из множества тегов XML. Он даже включает CSS для стилизации, внутри тега <defs>
– мы будем использовать его для изменения цветов. Когда вы закончите просматривать файл, измените его расширение на .svg
.
SVG можно хранить во внешних файлах или записывать в HTML. Чтобы сохранить чистоту нашего кода, мы будем хранить их во внешних файлах. Поэтому первое, что нам понадобится, это функция, которая получает данные SVG из внешних файлов. Откройте frontend/app.js
и добавьте следующий код вспомогательной функции над определением App
:
// helper function
async function fetchSvg(filename) {
let svgFile = await fetch(`svg/${filename}`);
let svgText = await svgFile.text();
const parser = new DOMParser();
return parser.parseFromString(svgText, "text/html").getElementsByTagName("svg")[0];
}
Здесь мы используем fetch()
для получения указанного файла из нашей директории svg
. Затем мы извлекаем текст файла, который будет строкой, и используем DOMParser
для преобразования этого текста в HTML. Это позволит нам манипулировать им с помощью стандартных функций JavaScript DOM.
Теперь у нас есть все необходимое для написания функции построения SVG. Эта функция должна делать следующее:
- Получить данные о цвете и аксессуарах для данного идентификатора токена.
- Получить SVG для головы ReplBot и изменить ее цвет в соответствии с данными ReplBot.
- Получите SVG для аксессуаров ReplBot и объедините их с головой ReplBot.
Добавьте приведенный ниже код в определение вашего App
внизу:
// SVG handling
createReplBotSVG: async function(tokenId) {
// get bot details
let details = await App.getReplBotDetails(tokenId);
// get bothead
let botSvg = await fetchSvg("bothead.svg");
// change bot colors
botSvg.querySelectorAll('.frame').forEach(f => {
f.style.fill = `rgb${details.colors[0]}`;
});
botSvg.querySelectorAll('.visor').forEach(v => {
v.style.fill = `rgb${details.colors[1]}`;
});
botSvg.querySelectorAll('.background').forEach(b => {
b.style.fill = `rgb${details.colors[2]}`;
});
},
Сначала мы получим детали ReplBot и базовый SVG ReplBot. Затем мы применим цвета бота, установив style.fill
для каждого экземпляра каждого из соответствующих классов, используя функцию CSS rgb()
с информацией о цвете из details
.
Далее нам нужно получить аксессуары бота. Добавьте следующий код над закрывающей фигурной скобкой этой функции (}
):
// get bot accessories
let accessorySvgs = [];
for (let i = 0; i < 3; i++) {
let filename = details.accessories[i].toLowerCase().replaceAll(" ","-") + ".svg";
let svg = await fetchSvg(filename);
accessorySvgs.push(svg);
}
Здесь мы получаем каждый из трех аксессуаров бота и добавляем их в список. Мы получаем имя файла для аксессуара путем преобразования строки, полученной из getReplBotDetails()
. Так, например, “Bunny Ears” становится “bunny-ears.svg”.
После того, как мы получили SVG наших аксессуаров, мы можем объединить их в базовый SVG ReplBot. Введите следующий код чуть ниже цикла for
:
// merge SVGs
accessorySvgs.forEach(a => {
Array.from(a.getElementsByTagName("style")).forEach(e => {
botSvg.getElementsByTagName("defs")[0].appendChild(e);
});
Array.from(a.getElementsByTagName("path")).forEach(e => {
botSvg.appendChild(e);
});
Array.from(a.getElementsByTagName("polyline")).forEach(e => {
botSvg.appendChild(e);
});
});
Мы объединяем SVG, добавляя все элементы <style>
в нашем вспомогательном SVG к элементу <defs>
в нашем базовом ReplBot SVG, и все элементы <path>
и <polyline>
вспомогательного SVG к основному телу SVG. Этого достаточно для коллекции SVG, которую мы используем в этом учебнике, но другие SVG могут содержать дополнительные элементы, которые необходимо будет учесть.
Наконец, мы добавим следующий код, который использует insertAdjacentHTML()
для добавления подробностей об ID и генерации ReplBot, прежде чем вернуть окончательный botSvg
во всей его красе. Добавьте следующий код непосредственно под кодом выше:
// add ID and generation details
botSvg.insertAdjacentHTML("beforeend", `<text x="5" y="20">ID: ${tokenId}</text>`);
botSvg.insertAdjacentHTML("beforeend", `<text x="5" y="40">Gen: ${details.parentage[0]}</text>`);
return botSvg;
Подключение пользовательского интерфейса
Мы написали всю функциональность нашего приложения – теперь нам просто нужно сделать ее пригодной для использования, связав наш HTML-интерфейс с нашими функциями JavaScript. Добавьте следующее определение функции в App.js
, чуть ниже определения switchToReplitTestnet
:
// interface
bindEvents: function () {
// mint
const mintButton = document.getElementById("mint");
mintButton.addEventListener("click", () => {
App.mintReplBot();
});
// breed
const breedForm = document.getElementById("breed");
breedForm.addEventListener("submit", (event) => {
event.preventDefault();
App.breedReplBot(breedForm.elements['parentOneId'].value, breedForm.elements['parentTwoId'].value);
});
},
Этот код будет вызывать mintReplBot()
при нажатии кнопки mint, и breedReplBot()
при отправке формы разведения ReplBot. Мы вызовем эту функцию в init
, добавив следующее:
init: async function() {
if (window.ethereum) {
await window.ethereum.request({ method: 'eth_requestAccounts' });
window.web3 = new Web3(window.ethereum);
// Switch networks
App.switchToReplitTestnet();
// Interface with contract
App.replbotContract = new web3.eth.Contract(replbotABI, App.replbotAddress);
}
App.bindEvents(); // <-- NEW LINE
},
Далее, давайте создадим функцию для отображения нашей коллекции NFT. Эта функция будет получать список NFT текущего пользователя и создавать SVG для каждого из них. Добавьте следующий код ниже определения bindEvents
:
populateCollection: async function() {
// get bot IDs
let botIds = await App.getMyReplBotIds();
// get container
let botContainer = document.getElementById("bots");
botContainer.innerHTML = ""; // clear current content
// create bot SVGs
botIds.forEach((id) => {
App.createReplBotSVG(id).then(result => {
botContainer.appendChild(result);
});
});
},
Мы захотим периодически вызывать эту функцию, чтобы поддерживать коллекцию ReplBot в актуальном состоянии. Для этого мы можем использовать setInterval
из JavaScript. Добавьте новую строку в определение bindEvents
, как показано ниже:
// interface
bindEvents: function () {
// mint
const mintButton = document.getElementById("mint");
mintButton.addEventListener("click", () => {
App.mintReplBot();
});
// breed
const breedForm = document.getElementById("breed");
breedForm.addEventListener("submit", (event) => {
event.preventDefault();
App.breedReplBot(breedForm.elements['parentOneId'].value, breedForm.elements['parentTwoId'].value);
});
// show collection
setInterval(App.populateCollection, 5000); // <-- new line
},
Это будет работать, но это будет воссоздавать все наши SVG раз в секунду, что обычно больше, чем нам нужно, и приведет к постоянному миганию страницы. Давайте добавим немного кэширования, чтобы предотвратить это. Сначала мы определим новый атрибут в верхней части нашего определения App
:
App = {
replbotAddress: "YOUR-CONTRACT-ADDRESS",
replbotContract: null,
ownedReplBots: [], // <-- NEW
....
}
Этот атрибут будет хранить наш список идентификаторов ReplBot. Каждый раз, когда мы будем вызывать populateCollection
, мы будем проверять, совпадает ли список, полученный из контракта, с нашим локальным списком. Если да, то мы пропустим остальную часть функции, в противном случае мы обновим кэш и продолжим. Добавьте следующий новый код в функцию populateCollection
:
populateCollection: async function() {
// get bot IDs
let botIds = await App.getMyReplBotIds();
// === NEW CODE BELOW ===
// check cache
if (compareArrays(botIds, App.ownedReplBots)) {
return; // array is unchanged
}
else {
App.ownedReplBots = botIds.slice(); // update cache and continue
}
// === NEW CODE ABOVE ===
// get container
let botContainer = document.getElementById("bots");
botContainer.innerHTML = "";
// create bot SVGs
botIds.forEach((id) => {
App.createReplBotSVG(id).then(result => {
botContainer.appendChild(result);
});
});
},
Обратите внимание на использование slice()
– это гарантирует, что ownedReplBots
содержит копию botIds
, а не ссылку на нее.
В JavaScript нет встроенной функции compareArrays()
, поэтому нам придется ее определить. Перейдите в верхнюю часть frontend/app.js
и добавьте следующий код ниже определения fetchSvg()
:
function compareArrays(array1, array2) {
// arrays must be same length
if (array1.length !== array2.length) {
return false
}
// arrays must be sorted
let array1Sorted = array1.slice().sort();
let array2Sorted = array2.slice().sort();
// all values must match
for (let i = 0; i < array1.length; i++) {
if (array1Sorted[i] !== array2Sorted[i]) {
return false;
}
}
return true;
}
Эта функция проверяет, что наши два массива одинаковой длины, сортирует их и затем сравнивает каждое значение. Если хоть одно значение отличается, возвращается false, в противном случае – true.
Запуск нашего приложения
Чтобы запустить наш фронтенд, мы создадим простое приложение Node.js Express. Создайте файл с именем frontend.js
в домашнем каталоге вашего repl и добавьте в него следующий код:
const express = require('express');
const app = express();
const PORT = 433;
app.use(express.static('frontend'));
app.listen(PORT, () => console.log(`Server listening on port: ${PORT}`));
Этот скрипт будет обслуживать файлы, которые мы создали в frontend
. Чтобы он выполнялся при запуске нашего repl, убедитесь, что файлы конфигурации отображаются, и откройте .replit
.
В .replit
замените строку run = "node tools"
на следующую:
run = "node frontend.js"
Запустите ваш repl. Теперь вы должны увидеть свой фронтенд. Если вы уже майнили какие-либо NFT ReplBot, они также появятся.
Что дальше?
В этом руководстве из двух частей мы создали полностью функциональное децентрализованное приложение, но вы можете еще многое сделать для его улучшения. Некоторые предложения:
- Реализовать интерфейс для просмотра ReplBot NFT, принадлежащих другим пользователям.
- Реализуйте интерфейс для перевода токенов ReplBot на другие адреса.
- Измените форму разведения, чтобы показать выпадающие меню ReplBot’ов пользователя.
- Использовать журнал событий контракта для отслеживания создания и рождения ReplBot.
- Изменить таблицу стилей dapp.
- Внедрите внешние компоненты для любой из новых функций, которые вы добавили в контракт после завершения первой части!
Вы можете найти наш repl здесь.