Для использования со смарт-контрактами NFT и децентрализованными приложениями Minting

Обзор

Методы, которые, как я подозреваю, известны большинству людей для обработки предпродажного/белкового списка, — это использование массива (дорого!), дерева Меркла (жесткого и болезненного) или стандарта EIP-712. Приведенные ниже шаги описывают альтернативный подход с применением купонов / ваучеров с использованием ECSign и ECRecover, которые полностью безгазовые и чрезвычайно гибкие. В этой статье подробно описаны шаги по настройке собственного белого списка без газа.

Для справки, пример проекта собран в этом репозитории Github. Обратите внимание, что этот проект предназначен только для примера и должен использоваться для дополнения того, что вы знаете, и предоставления ориентиров для применения такого подхода. Он исключает многие вещи, которые должен включать полностью конкретизированный проект, и не предназначен для решения под ключ.

Ключевые разделы

  1. Подписчик купонов: создание закрытых и открытых ключей, необходимых для подписи наших купонов.
  2. Проверка смарт-контракта: настройка структуры и метода проверки в нашем смарт-контракте для наших купонов.
  3. Создание купонов: создание функций для создания и обновления наших купонов вне сети.
  4. DApp Coupon Mint: настройка нашего dApp для включения купонов в нашу функцию монетного двора.

Подписавший купон

Первое, что нам понадобится для наших купонов, — это пара закрытый/открытый ключ от кошелька, которая будет использоваться для подписи купонов. Эта пара ключ/значение не должна представлять существующий кошелек и не должна быть связана с чем-либо еще в вашем проекте. Хотя мы могли бы использовать существующий кошелек, я рекомендую создать случайный кошелек. Закрытый ключ, сгенерированный из этого кошелька, будет использоваться для создания хешированной подписи, которая будет возвращать действительный ответ только при проверке открытого ключа в нашем методе проверки. Купон, подписанный любым другим закрытым ключом, или купон, который был изменен без повторной подписи с помощью закрытого ключа, не пройдет проверку. Именно по этой причине наш закрытый ключ ДОЛЖЕН оставаться должным образом защищенным и скрытым! В случае потери или раскрытия закрытого ключа нам потребуется сгенерировать новый набор купонов с новым закрытым ключом, соответствующим образом обновив открытый ключ (используемый для проверки купонов) в нашем смарт-контракте. К счастью, это легко сделать… но постарайтесь не потерять этот ключ.

Создание случайного кошелька

Откройте терминал (CMD) — нам потребуются установленные Node и Ethers.js. Внутри терминала выполните следующие команды:

node
const ethers = require('ethers')
const wallet = ethers.Wallet.createRandom()
console.log('Public Address:', wallet.address)
console.log('Private Key:', wallet.privateKey)

Вот оно. Простой. Если вы потеряете эту пару ключей, просто повторите этот процесс, заново сгенерируйте заменяющие купоны и обновите публичный адрес в своем смарт-контракте!

Проверка смарт-контракта

Подход, который мы будем использовать, — это многоразовый купон — купон, который связан с адресом кошелька и включает в себя определенное количество монетных дворов, выделенных для него. В отличие от одноразового купона, эти купоны могут быть повторно использованы держателем до тех пор, пока не будет достигнуто максимальное количество выделенных монетных дворов. Нам понадобится несколько элементов для правильного отслеживания и проверки наших купонов: объекты структуры, объект перечисления, переменная сопоставления адресов и метод проверки.

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

Структуры

Объект struct позволяет нам определить структуру типа данных, которую мы будем использовать при создании наших купонов и отслеживании монетных дворов купонов. Если вы ожидаете, что ваши типы купонов изменятся после развертывания, вы можете включить метод для обновления этой переменной. Для целей этого объяснения мы предположим, что типы купонов не будут меняться, и наша структура будет неизменной после развертывания контракта.

Первая структура, которую мы определяем, — это MintTypes, которая будет использоваться для различения возможных типов купонов, отчеканенных каждым адресом:

struct MintTypes {
    uint256 _presaleMintsByAddress;
    uint256 _teamMintsByAddress;
}

Вторая структура, которую мы определяем, — это Coupon, которая будет использоваться для сопоставления структуры созданной подписи (которая возвращает объект с компонентами r, s и v из подписи) и проверки купонов, которые мы генерируем вне сети:

struct Coupon {
    bytes32 r;
    bytes32 s;
    uint8 v;
}

Перечисление

Мы будем использовать переменную Enum, чтобы указать используемые типы купонов. Это перечисление будет использоваться для создания дайджеста (закодированного хэша типа купона, выделенной суммы и отправителя сообщения), который используется для проверки полученного купона. Примечание. Внутри перечисления обрабатываются как числа; вы увидите, что это применено, когда мы создадим наши купоны. Опять же, если вы ожидаете, что ваши типы купонов изменятся после развертывания, вы можете включить метод для обновления этой переменной. Для целей этого объяснения мы предположим, что типы купонов не будут меняться и что наше перечисление будет неизменным после развертывания контракта. Мы будем использовать два типа купонов: «Предпродажа» (для нашей предпродажной чеканки по сниженной цене) и «Командный» (для бесплатных монет, выделяемых на вклад членов команды в проект). Это можно легко расширить, чтобы создать «Маркетинг», «Скидка» или любое количество альтернативных вариантов использования.

enum CouponType {
    Presale,
    Team
}

Сопоставление адресов

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

mapping(address => MintTypes) public addressToMinted;

Метод проверки

Мы будем использовать вспомогательный метод для проверки того, что восстановление купона (с помощью ecrecover, о котором вы можете прочитать подробнее здесь) возвращает адрес, который соответствует ожидаемому общедоступному адресу нашего кошелька для подписи. Подделка существующего купона или попытка подписать купон с помощью другого кошелька приведет к неправильному ответу и сбою нашей проверки. Поскольку мы включаем кошелек, которому назначен купон, тип купона и выделенную сумму в подписи купона, мы можем надежно предотвратить подделку купона.

/**
 * * Verify Coupon
 * @dev Verify that the coupon sent was signed by the coupon signer and is a valid coupon
 * @notice Valid coupons will include coupon signer, type [Presale, Team, Discount], address, and allotted mints
 * @notice Returns a boolean value
 * @param digest The digest
 * @param coupon The coupon
 */
function _isVerifiedCoupon(bytes32 digest, Coupon memory coupon) internal view returns (bool) {
    address signer = ecrecover(digest, coupon.v, coupon.r, coupon.s);
    require(signer != address(0), 'Zero Address');
    return signer == couponSigner;
}

Использование проверки

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

/**
 * * Mint Presale Tokens
 * @dev Minting function for tokens available during the Presale phase
 * @notice Minting Presale tokens requires a valid coupon, associated with wallet and allotted amount
 * @param qty The number of tokens being minted by sender
 * @param allotted The allotted number of tokens specified in the Presale Coupon
 * @param coupon The signed coupon
 */
function mintPresale(uint256 qty, uint256 allotted, Coupon memory coupon) external {
    // Verify phase is not locked
    require(phase == SalePhase.Presale, "Presale Not Active");
    // Create digest to verify against signed coupon
    bytes32 digest = keccak256(
        abi.encode(CouponType.Presale, allotted, _msgSender())
    );
    // Verify digest against signed coupon
    require(_isVerifiedCoupon(digest, coupon), "Invalid Coupon");
    // Verify quantity (including already minted presale tokens) does not exceed allotted amount
    require( qty + addressToMinted[_msgSender()]._presaleMintsByAddress < _allotted + 1, "Exceeds Max Allotted");
    require(msg.value == qty * presaleMintPrice, "Incorrect Payment")
    // Increment number of presale tokens minted by wallet
    addressToMinted[_msgSender()]._presaleMintsByAddress += qty;
    // Mint Reserve Tokens
    _safeMint(_msgSender(), qty);
}

Собираем все вместе

Вы можете найти пример смарт-контракта, использующего элементы, описанные выше, в исходном коде этого репозитория Github.

Создание купонов

Мы собираемся генерировать наши купоны в отдельном приложении, надежно отделенном от всего остального. Для этого вам понадобится несколько пакетов, установленных в папке вашего проекта.

Мы собираемся генерировать наши купоны в отдельном приложении, надежно отделенном от всего остального. Для этого вам понадобится несколько пакетов, установленных в папке вашего проекта.

Настройка среды

Откройте терминал и перейдите в нужную папку проекта. В терминале выполните следующие команды:

npm i create-next-app coupon-generator
cd coupon-generator
npm i ethereumjs-util ethers
npm run dev

Откройте свой проект в Visual Studio Code (или предпочитаемой вами IDE)

Добавить переменные среды

Создайте файл .env.local в корне вашего проекта (убедитесь, что ваш файл gitignore игнорирует файлы .env — даже если вы не собираетесь выпускать этот код в производство, всегда лучше перестраховаться!) Добавить следующие переменные в ваш файл .env.local:

COUPON_SIGNING_KEY="YOUR_GENERATED_PRIVATE_KEY"
COUPON_PUBLIC_KEY="YOUR_GENERATED_PUBLIC_ADDRESS"

Примечание. PrivateKey, сгенерированный на предыдущем шаге, будет содержать «0x», который необходимо удалить, чтобы он правильно буферизировался до ожидаемого значения (Uint8Array / Length 32).

Загрузить списки адресов купонов

Мы будем использовать файлы JSON для загрузки нашего списка адресов и связанных с ними монетных дворов, которые позже будут преобразованы в купоны.

  • Создайте папку с именем «lib» в корне вашего проекта.
  • Создайте файл с именем presaleAddressList.json в новой папке /lib.
  • Создайте файл с именем teamAddressList.json в новой папке /lib.
  • Внутри каждого из этих файлов добавьте связанный список адресов и их выделений монетного двора со следующей структурой:
{
    "0xd242F13432452e0A8b02768224b1D35885b4f8B2": 2,
    "0x5700E2AAEB6aE0a0985E94d6505bCC058ebFaA64": 4,
    "0x48fc468c2291badaCd0B46f233a0F3e217b329f9": 1,
    "0x912f9b9337F84648d08D4399aaF10FafDaC1Fe30": 2
}

Создать папку для купонов

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

  • Создайте папку с названием «Купоны» в корне вашего проекта.

Создайте конечную точку

Мы будем создавать наш скрипт генерации купонов в качестве конечной точки API в нашем проекте NextJS. Хотя мы не будем настраивать это как настоящий API со всеми прибамбасами, такая настройка позволяет расширить возможности реализации и работы с генерацией купонов (например, создание полнофункционального UI/UX для создание случайного кошелька, загрузка данных списка адресов и более динамичная обработка всех движущихся частей с одной страницы приложения).

Создайте файл retrieveCoupons.js в папке /api вашего проекта. Импортируйте следующие функции из наших установленных пакетов в верхней части этого файла:

import { keccak256, toBuffer, ecsign, bufferToHex } from 'ethereumjs-util';
import { ethers } from 'ethers';
const fs = require('fs')

Добавьте экспортированную функцию-обработчик по умолчанию — остальная часть нашего кода будет добавлена ​​внутрь этой функции:

export default function handler(req, res) {
    // code will go here...
}

Давайте начнем с установки переменной «тип», которую мы ожидаем получить в качестве параметра запроса для нашей конечной точки (что позволит нам генерировать купоны соответствующего типа купона). Мы также добавим две условные проверки, чтобы убедиться, что параметр запроса был правильно отправлен с запросом API:

let {type} = req.query
if (type === undefined){
    res.status(400).json({message:'Error: Invalid Request - "type" is a required parameter'})
    return
}
if (type !== "presale" && type !== "team"){
    res.status(400).json({message:'Error: Invalid Request - valid "type" values are "presale" and "team"'})
    return
}

Затем мы добавим пустую объектную переменную, в которую мы в конечном итоге загрузим все наши купоны:

let coupons = {};

Затем импортируйте закрытый ключ нашего случайно сгенерированного кошелька для подписи из переменных среды, используя служебную функцию Buffer для преобразования строки в буферизованное шестнадцатеричное значение:

const signerPvtKeyString = process.env.COUPON_SIGNING_KEY || "";
const signerPvtKey = Buffer.from(signerPvtKeyString, "hex");

Теперь давайте импортируем списки адресов, используя оператор switch для импорта правильного файла списка адресов, связанного с типом купона, полученным параметром типа:

let addressList
    switch(type) {
        case "presale":
            addressList = require('/lib/presaleAddressList.json')
            break;
        case "team":
            addressList = require('/lib/teamAddressList.json')
            break;
}

Затем нам нужно создать объектную переменную, чтобы воспроизвести структуру переменной enum Coupon, которую мы добавили в наш смарт-контракт.

Важно: перечисляемые переменные в Solidity преобразуются в числа с индексом 0, поэтому важно, чтобы структура этой переменной точно соответствовала нашей переменной перечисления смарт-контракта:

const CouponTypeEnum = {
    Presale: 0,
    Team: 1
}

Далее мы собираемся создать несколько вспомогательных функций, которые будут использоваться внутри функции generateCoupons(), которую мы создадим позже.

createCoupon() принимает хешированное значение и буферизованный шестнадцатеричный код закрытого ключа из нашего кошелька для подписи и использует функцию ecsign ethereumjs-util для возврата подписи ECDSA.

generateHashBuffer() берет наши типы и массивы значений и использует функции ethereumjs-util toBuffer и keccak256 для возврата буферизованного хэша keccak256.

serializeCoupon() берет наш подписанный купон и сериализует его в нашем последнем объекте «coupon»

function createCoupon(hash, signerPvtKey) {
    return ecsign(hash, signerPvtKey);
}
function generateHashBuffer(typesArray, valueArray) {
    return keccak256(
        toBuffer(ethers.utils.defaultAbiCoder.encode(typesArray,valueArray))
    )
}
function serializeCoupon(coupon) {
    return {
        r: bufferToHex(coupon.r),
        s: bufferToHex(coupon.s),
        v: coupon.v,
    }
}

Эта следующая часть предназначена для создания и вызова нашей основной функции generateCoupons(). Здесь МНОГО всего происходит, поэтому я сделал все возможное, чтобы добавить комментарии, разбивающие каждую из частей, а не пытаться объяснить каждую часть по отдельности:

/**
 * * generateCoupons
 * @dev This function iterates through a list of addresses, converting them
 * to a signed hash of the coupon details and writing them out to a JSON file
 */
const generateCoupons = () => {
  try {
    // Iterate through addresses list
    for ( const [address, qty] of Object.entries(addressList) ) {
      
      // Verify that the address is a valid address (many presale/allowlist signups include invalid addresses)
      if (ethers.utils.isAddress(address)){
        // Set userAddress to a Checksum Address of the address
        // If address is an invalid 40-nibble HexString or if it contains mixed case 
        //   and the checksum is invalid, an INVALID_ARGUMENT Error is thrown.
        // The value of address may be any supported address format.
        const userAddress = ethers.utils.getAddress(address);
      
        // Set our Coupon Type
        let couponType
        switch(type) {
          case "presale":
              couponType = CouponTypeEnum["Presale"]
              break;
          case "team":
              couponType = CouponTypeEnum["Team"]
              break;
        }
        // Call our helper function to generate the hash buffer
        const hashBuffer = generateHashBuffer(
          ["uint256", "uint256", "address"],
          [couponType, qty, userAddress]
        );
      
        // Call our helper function to sign our hashed buffer and create the coupon
        const coupon = createCoupon(hashBuffer, signerPvtKey);
        // Add the wallet address with allotted mints and coupon to our coupons object
        coupons[userAddress.toLowerCase()] = {
          qty,
          coupon: serializeCoupon(coupon)
        }
      } else {
        // Kick out a log of addresses that were flagged as invalid
       console.log(address + ': Invalid Address')
      }
    }
    // Convert our coupons object to a readable string
    const writeData = JSON.stringify(coupons, null, 2);
    // Write our coupons to a JSON file based on the type param received
    fs.writeFileSync(`coupons/${type}Coupons.json`, writeData);
  } catch (err) {
    // Log errors and send associated response status code with error message
    console.error(err)
    res.status(500).json({ message: err })
  }
}
// Call our generateCoupons function
generateCoupons();

При условии, что все прошло хорошо и ничего не сломалось! Мы завершим нашу конечную точку окончательным ответом об успехе:

res.status(200).json({ message: 'Success!'})

Монетный двор купона dApp

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

Импорт купонов

Для начала нам нужно будет создать доступ к нашим купонам. Это можно сделать несколькими способами: загрузить их в базу данных и запросить на основе адреса кошелька, загрузить их в папку в нашем приложении Minting dApp и запросить через конечную точку API или просто импортировать их напрямую. Для простоты в этом примере мы будем импортировать JSON напрямую:

const presaleCoupons = require('./coupons/presaleCoupons.json');
const teamCoupons = require('./coupons/teamCoupons.json');

Проверка

Предполагая, что у нас есть подключенный кошелек — скажем, вы присвоили его переменной «currentAccount» — теперь мы можем использовать функцию проверки для проверки действительного купона. В зависимости от вашего dApp и варианта использования существует множество реализаций, и я не буду вдаваться в подробности того, как именно вы должны обрабатывать проверку на стороне клиента, но некоторые примеры проверок могут быть следующими…

Проверить наличие действительного купона:

const hasPresaleCoupon = () => {
    const couponExists = (presaleCoupons[currentAccount] === undefined) ? false : true
    return couponExists
}

Ограничьте максимальное количество отчеканенных монет (Примечание: вы также можете проверить сумму, которую они уже отчеканили, и соответственно уменьшить их максимальный прирост в строке 4):

const incrementCounter = () => {
    let maxIncrement
    if ( phase === 3 && hasPresaleCoupon() ){
        maxIncrement = coupons[currentAccount].qty
    } else {
        maxIncrement = maxPerTx
    }
    if (mintQty + 1 > maxIncrement){
        setMintQty(maxIncrement)
    } else {
        setMintQty(mintQty + 1);
    }
}

Предотвратить попытку чеканки, если в подключенном кошельке нет действительного купона:

const mintPresaleNfts = async () => {
    if ( !hasPresaleCoupon() ){
        alert('You do not have a Presale Coupon! Why are you trying to waste gas?!')
        return
    }
    ...
}

Транзакция монетного двора с купоном

Основываясь на функции mintPresaleNfts, приведенной в качестве примера в нашем разделе «Проверка смарт-контракта» выше, мы хотим передать 3 параметра (вместе с нашим объектом опций) в функцию mintPresaleNfts в контракте: количество, выделено, купон. Ниже приведен краткий и простой пример функции монетного двора (с использованием EthersJS с MetaMask в качестве провайдера), использующей созданные нами купоны:

try {
  const { ethereum } = window
  if (ethereum) {
    const provider = new ethers.providers.Web3Provider(ethereum)
    const signer = provider.getSigner()
    const nftContract = new ethers.Contract(
      nftContractAddress,
      NFT.abi,
      signer
    )
    const totalPrice = presalePriceInEther * mintQty
    const options = {
      value: ethers.utils.parseEther(String(totalPrice))
    }
    let nftTx = await nftContract.mintPresale(mintQty,presaleCoupons[currentAccount].qty,presaleCoupons[currentAccount].coupon,options)
    console.log('Minting....', nftTx.hash)
    let tx = await nftTx.wait()
    console.log('Mined!', tx)
    let events = tx.events
    events.forEach((v,i) => {
      console.log(String(event[i].args.tokenId._hex))
    })
    
  } else {
    console.error("Ethereum object doesn't exist!")
  }
} catch (error) {
  console.error('Error Minting: ', error)
}

И вот оно! Образец проекта, объединяющий все это, можно найти в этом репозитории GitHub. Опять же, для краткости, этот образец проекта предназначен только для справки и не включает ряд проверок и методов, которые должны быть включены в полностью готовый к производству проект.

Кредит:

Lacuna Strategies, LLC
Discord: @Rhaphie#3352
Twitter: @LacunaStrats