Привет, Хабр. 

В прошлых статьях мы научились генерировать 10 000 изображений для нашей NFT коллекции с помощью Golang, а также загрузили все сгенерированные изображения в децентрализованное хранилище IPFS.

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

Мы создадим типовой смарт-контракт для нашей NFT коллекции, протестируем и загрузим созданный смарт-контракт в тестовую сеть Ethereum. Но прежде, чем мы приступим к кодингу, мне хотелось бы остановиться на ERC-721 стандарте, данный стандарт описывает спецификацию NFT токенов.

Давайте подробней рассмотрим, какие методы должны быть у нашего смарт-контракта:

    function balanceOf(address _owner) external view returns (uint256);
    function ownerOf(uint256 _tokenId) external view returns (address);
    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
    function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
    function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
    function approve(address _approved, uint256 _tokenId) external payable;
    function setApprovalForAll(address _operator, bool _approved) external;
    function getApproved(uint256 _tokenId) external view returns (address);
    function isApprovedForAll(address _owner, address _operator) external view returns (bool);

Полную спецификацию по ERC-721 стандарту можно посмотреть тут.

Нам не обязательно реализовывать весь стандарт самим, более оптимальным подходом является переиспользовать готовые библиотеки: OpenZeppelin - это библиотека для разработки безопасных смарт-контрактов и именно с ней мы будем работать.

Плюсы такого решения очевидны:

  • Готовый код из под коробки, прошедший аудит безопасности

  • Аудит нашего смарт-контракта займет гораздо меньше денег и времени

Создание смарт-контракта

Давайте создадим типовой смарт-контракт для нашей NFT коллекции, назовем её MonkeyNFT. Наш смарт-контракт наследует стандартные OpenZeppelin библиотеки, а именно:

  • ERC721 / ERC721Enumerable - контрактные модули, которые обеспечивают базовые функциии для нашего NFT токена

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

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

Запускаем команду npx hardhat init, для создания нового шаблона для нашего смарт-контракта, далее переходим в директорию contracts и пишем код:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Context.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract MonkeyNFT is ERC721, ERC721Enumerable, Ownable {

    using SafeMath for uint256;
    uint public constant maxPurchase = 10;
    uint256 public constant MAX_MONKEYS = 10000;

    uint256 private _monkeyPrice = 80000000000000000; //0.08 ETH
    string private baseURI;
    bool public saleIsActive = true;

    constructor() ERC721("The Monkey NFT", "MNK") {
    }
    
    function _beforeTokenTransfer(address from, address to, uint256 tokenId)
        internal
        override(ERC721, ERC721Enumerable)
    {
        super._beforeTokenTransfer(from, to, tokenId);
    }
    
    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721Enumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }

		function withdraw() public onlyOwner {
			uint256 balance = address(this).balance;
			payable(msg.sender).transfer(balance);
		}    

    function setPrice(uint256 _newPrice) public onlyOwner() {
        _monkeyPrice = _newPrice;
    }

    function getPrice() public view returns (uint256){
        return _monkeyPrice;
    }

    function mintMonkeys(uint numberOfTokens) public payable {
        require(saleIsActive, "Sale must be active to mint Monkeys");
        require(numberOfTokens <= maxPurchase, "Can only mint 10 tokens at a time");
        require(totalSupply().add(numberOfTokens) <= MAX_MONKEYS, "Purchase would exceed max supply of Monkeys");
        require(_monkeyPrice.mul(numberOfTokens) <= msg.value, "Ether value sent is not correct");
        
        for(uint i = 0; i < numberOfTokens; i++) {
            uint mintIndex = totalSupply();
            if (totalSupply() < MAX_MONKEYS) {
                _safeMint(msg.sender, mintIndex);
            }
        }
    }      

    function _baseURI() internal view override returns (string memory) {
        return baseURI;
    }
    
    function setBaseURI(string memory newBaseURI) public onlyOwner {
        baseURI = newBaseURI;
    }

    function flipSaleState() public onlyOwner {
        saleIsActive = !saleIsActive;
    }  
}

Из интересного:

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

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

  • Функция mintMonkey - это основная функция, через которую пользователи могут купить наш NFT токен. Модификатор payable, как раз говорит о том, что вызов данной функции для пользователя платный.

Для того, чтобы скомпилировать наш смарт-контракт, запускаем команду npx hardhat compile, после чего создадутся abi-артифакты в директории artifacts.

Тестирование смарт-контракта

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

const { expect } = require("chai");

describe("Token contract", () => {

  let contract;
  let owner;
  let addr1;
  let addr2;
  let addrs;
  let baseURI;

  beforeEach(async () => {
    const Token = await ethers.getContractFactory("MonkeyNFT");
    [owner, addr1, addr2, ...addrs] = await ethers.getSigners();
    contract = await Token.deploy();
    baseURI = "https://hardhat.org/test/"
    await contract.setBaseURI(baseURI)
  });

  it("Should initialize contract", async () => {
    expect(await contract.MAX_MONKEYS()).to.equal(10000);
  });
  it("Should set the right owner", async () => {
    expect(await contract.owner()).to.equal(await owner.address);
  });
  it("Should mint", async () => {
    const price = await contract.getPrice();
    const tokenId = await contract.totalSupply();
    expect(
      await contract.mintMonkeys(1, {
        value: price,
      })
    ).to.emit(contract, "Transfer").withArgs(ethers.constants.AddressZero, owner.address, tokenId);  
    expect(await contract.tokenURI(tokenId)).to.equal(baseURI+"0");
  });
});

Запускаем тестирование смарт-контракта, командой npx hardhat test. Если все тесты пройдены, то мы увидем:

Compiling 1 file with 0.8.4
Compilation finished successfully


  Token contract
    ✓ Should initialize contract
    ✓ Should set the right owner
    ✓ Should mint (41ms)


  3 passing (1s)

Только, что мы успешно протестировали:

  • Создание смарт-контракта

  • Успешно установили владельца смарт-контракта

  • Успешно вызвали платную функцию mintMonkeys для продажи NFT токена

Деплой смарт-контракта

Деплоить мы будем в тестовую сеть Ethereum. Переходим в директорию scripts и удаляем дефолтные скрипты, они нам больше не понадобяться и пишем новый скрипт деплоя:

async function main() {
    const [deployer] = await ethers.getSigners();
  
    console.log("Deploying contracts with the account:", deployer.address);
  
    console.log("Account balance:", (await deployer.getBalance()).toString());
  
    const Token = await ethers.getContractFactory("MonkeyNFT");

    console.log("Deploying contract...");
    const token = await Token.deploy();
    await token.deployed();
    console.log("Token address:", token.address);
  }
  
  main()
    .then(() => process.exit(0))
    .catch((error) => {
      console.error(error);
      process.exit(1);
    });

Так же нам необходимо изменить файл hardhat.config следующим образом:

require("@nomiclabs/hardhat-waffle");
require("@nomiclabs/hardhat-etherscan");

module.exports = {
  solidity: "0.8.4",
  networks: {
    rinkeby: {
      url: `https://eth-rinkeby.alchemyapi.io/v2/${YOUR_API_KEY}`,
    },
  },
};

Отлично все готово для деплоя, запускаем скрипт: npx hardhat run scripts/deploy.js --network rinkeby

В следующей статье я расскажу как взаимодействовать с нашим созданным смарт-контрактом с помощью web3.js.

Комментарии (2)


  1. korsetlr473
    18.12.2021 14:00

    1) почему ERC721Enumerable  , а не ERC721URIStorage ?

    2) почему вы используете какието свои контукции для метаданных , а не IERC721Metadata?

    3) как можно установить картинку для контакта? я видел на etherscan когда заходишь у некоторых картинка отображается

    4) как можно сжешь токен?

    5) от вопроса 4. как то можно соединить два nft в один? или это просто сжечь две штуки и сминтить новый?

    6) вы указали цену 80000000000000000 , они перемещаются на кошелек кто создал смартконтакт?

    7) для чего нужны supportsInterface и beforetransfer ?


    1. monomoto Автор
      20.12.2021 14:45

      1) Если у NFT коллекции baseURL общий, то нет необходимости хранить URL для каждого токена отдельно, т.к. его можно получить по формуле: baseURL/${TOKEN_ID}. В нашем случае, базовый URL был общий, поэтому это был самый оптимальный вариант.

      2) Нам было очень важно торговаться на бирже opensea, у них есть документация, в каком формате должны быть аттрибуты.

      3) Нужно выполнить верификацию на ehterscan, например с помощью hardhat достаточно запустить скрипт npx hardhat verify --network mainnet YOU_CONTRACT_ID

      4) Нужно реализовать OpenZeppelin ERC721Burnable

      5) Да, именно так и делают большинство NFT проектов

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

          function withdrawAll() public payable onlyOwner {
              uint256 _each = address(this).balance / 2;
              require(payable(developer).send(_each));
              require(payable(ceo).send(_each));
          }