Построение роботизированного НФТ на блокчейне Ethereum с помощью Solidity и Replit (часть 2)

В первой части этого руководства мы написали смарт-контракт 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: "",
                        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. Эта функция должна делать следующее:

  1. Получить данные о цвете и аксессуарах для данного идентификатора токена.
  2. Получить SVG для головы ReplBot и изменить ее цвет в соответствии с данными ReplBot.
  3. Получите 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 здесь.

Оцените статью
Procodings.ru
Добавить комментарий