Учебное пособие по смарт-контрактам для начинающих — часть 2

В Части 1 мы создали функционал для нашего лотереи dApp. Во второй части мы интегрируем API3 QRNG в наш контракт и развернем его в общедоступной тестовой сети Ethereum Goerli. В качестве альтернативы вы можете использовать другую поддерживаемую цепочку вместо Goerli, выполнив те же действия и заменив их соответствующим образом.

Мы будем использовать бесплатный QRNG API3, чтобы добавить в наш контракт действительно случайные числа. API3 QRNG использует Airnode, чтобы позволить ANU предоставлять свои услуги для сценариев использования блокчейна и Web3 без каких-либо сторонних посредников.

Вилка

Как упоминалось в части 1, Hardhat — это среда разработки EVM, которая позволяет нам развертывать смарт-контракты в сетях EVM и, в этом случае, запускать локально работающий экземпляр блокчейна для тестирования нашего контракта. Мы можем сделать это, настроив Hardhat на форк тестовой сети Ethereum Goerli, который будет локально копировать состояние общедоступной сети. Нам понадобится RPC или конечная точка, которая соединяет Hardhat с блокчейном. Ниже мы будем использовать Alchemy в качестве нашего поставщика RPC.

1. Окружение точки

Мы собираемся использовать конфиденциальные учетные данные на следующих шагах. Мы будем использовать пакет DotEnv для хранения этих учетных данных как переменных среды отдельно от кода нашего приложения:

npm install dotenv

Затем создайте файл .env в корне вашего проекта.

Создайте аккаунт Алхимия. Нажмите кнопку СОЗДАТЬ ПРИЛОЖЕНИЕ и выберите тестовую сеть Goerli из выпадающего меню, так как остальные доступные тестовые сети устарели или скоро будут устаревшими. Вставьте только что сгенерированный URL-адрес конечной точки Goerli RPC в файл .env:

RPC_URL="{PUT RPC URL HERE}"

Затем добавьте следующее в начало файла hardhat.config.js, чтобы сделать значения в файле .env доступными в нашем коде:

require("dotenv").config();

2. Настройте Hardhat для использования форка

Добавив следующее к нашему module.exports в файле hardhat.config.js, мы говорим Hardhat сделать копию сети Goerli для использования в локальном тестировании:

module.exports = {
  solidity: "0.8.9",
  networks: {
    hardhat: { // Hardhat local network
      chainId: 5, // Force the ChainID to be 5 (Goerli) in testing
      forking: { // Configure the forking behavior
        url: process.env.RPC_URL, // Using the RPC_URL from the .env
      },
    },
  },
};

Превратите контракт в Airnode Requester

В качестве запрашивающей стороны наш контракт Lottery.sol сможет отправлять запросы к воздушному узлу, в частности к воздушному узлу ANU QRNG, используя протокол запроса-ответа (RRP). Возможно, будет полезно потратить немного времени на ознакомление, если вы еще этого не сделали.

1. Установите зависимости

npm install @api3/airnode-protocol

2. Импортируйте Airnode Requester в контракт

В верхней части Lottery.sol, ниже версии Solidity, импортируйте Контракт Airnode RRP из Реестра npm:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
contract Lottery is RrpRequesterV0{

Нам нужно установить публичный адрес RRP-контракта Airnode в цепочке, которую мы используем. Мы можем сделать это в конструкторе, сделав адрес RRP аргументом:

constructor(uint256 _endTime, address _airnodeRrpAddress)
    RrpRequesterV0(_airnodeRrpAddress)
{
    endTime = _endTime;
}

3. Тест

В верхней части файла test/Lottery.js импортируйте пакет протокола Airnode:

const airnodeProtocol = require("@api3/airnode-protocol");

Нам нужно передать в конструктор адрес RRP-контракта. Мы можем сделать это, добавив следующее в наш тест Deploys:

it("Deploys", async function () {
  const Lottery = await ethers.getContractFactory("Lottery"); 
  accounts = await ethers.getSigners(); 
  nextWeek = Math.floor(Date.now() / 1000) + 604800;
  // Get the chainId we are using in Hardhat  
  let { chainId } = await ethers.provider.getNetwork(); 
  // Get the AirnodeRrp address for the chainId 
  const rrpAddress = airnodeProtocol.AirnodeRrpAddresses[chainId];
  lotteryContract = await Lottery.deploy(nextWeek, rrpAddress); 
  expect(await lotteryContract.deployed()).to.be.ok;
});

Запустите npx hardhat test, чтобы попробовать.

Настроить Airnode

1. Параметры

Добавим в наш контракт наши Параметры Airnode. В Lottery.sol добавьте к глобальным переменным следующее:

// ANU's Airnode address
address public constant airnodeAddress = 0x9d3C147cA16DB954873A498e0af5852AB39139f2; 
// ANU's uint256 endpointId
bytes32 public constant endpointId = 0xfb6d017bb87991b7495f563db3c8cf59ff87b09781947bb1e417006ad7f55a78; 
// We'll store the sponsor wallet here later
address payable public sponsorWallet;

airnodeAddress и endpointID конкретного Airnode можно найти в документации провайдера API, которым в данном случае является API3 QRNG. Мы установим их как constant, так как они не должны меняться.

2. Установить спонсорский кошелек

Чтобы оплатить выполнение запросов Airnode, обычно нам нужно спонсировать запросчика, наш Lottery.sol контракт. В этом случае, если мы используем адрес контракта в качестве спонсора, он автоматически спонсирует себя.

Нам нужно сделать наш контракт «пригодным для собственности». Это позволит нам ограничить доступ для установки sponsorWallet к владельцу контракта (кошелек/учетная запись, которая развернула контракт).

Сначала импортируйте контракт Ownable вверху Lottery.sol:

import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Lottery is RrpRequesterV0, Ownable {

Затем мы можем сделать нашу функцию setSponsorWallet и присоединить модификатор onlyOwner для ограничения доступа:

function setSponsorWallet(address payable _sponsorWallet) 
external onlyOwner {
    sponsorWallet = _sponsorWallet;
}

3. Тест

Мы получим наш sponsorWallet, используемый для финансирования транзакций Airnode, используя функции из @api3/airnode-admin package/CLI tool.

npm install @api3/airnode-admin

Затем мы можем импортировать его в наш файл tests/Lottery.js:

const airnodeAdmin = require("@api3/airnode-admin");

Мы жестко закодируем Адрес Xpub и Airnode ANU QRNG, чтобы получить наш sponsorWalletAddress. Добавьте следующий тест под тестом Развертывание:

it("Sets sponsor wallet", async function () {
    const anuXpub = "xpub6DXSDTZBd4aPVXnv6Q3SmnGUweFv6j24SK7
7W4qrSFuhGgi666awUiXakjXruUSCDQhhctVG7AQt67gMdaRAsDnDXv23bBRKsMWvRzo6kbf"

    const anuAirnodeAddress =
       "0x9d3C147cA16DB954873A498e0af5852AB39139f2"

    const sponsorWalletAddress = await
       airnodeAdmin.deriveSponsorWalletAddress(
         anuXpub,
         anuAirnodeAddress,
         lotteryContract.address // used as the sponsor
       );


    await expect(lotteryContract.connect(accounts[1])
      .setSponsorWallet(sponsorWalletAddress)).to.be.reverted;

    await lotteryContract.setSponsorWallet(sponsorWalletAddress);
    expect(await lotteryContract.sponsorWallet())
      .to.equal(sponsorWalletAddress);
});

запустите npx hardhat test, чтобы проверить свой код.

Написать функцию запроса

В контракте Lottery.sol добавьте следующую функцию:

function getWinningNumber() external payable {
  // require(block.timestamp > endTime, "Lottery has not ended");
  require(msg.value >= 0.01 ether, "Please top up sponsor wallet"); 
  bytes32 requestId = airnodeRrp.makeFullRequest(
      airnodeAddress,
      endpointId,
      address(this), // Sponsor
      sponsorWallet,
      address(this), // Return the response to this contract
      this.closeWeek.selector, // Fulfill function
      "" // No params
  );
  pendingRequestIds[requestId] = true; 
  emit RequestedRandomNumber(requestId); 
  sponsorWallet.call{value: msg.value}(""); 
}

Мы оставим строку 2 закомментированной для простоты тестирования. В строке 3 мы требуем, чтобы пользователи отправляли дополнительный газ в кошелек спонсора, чтобы у Airnode был газ для вызова нашей функции выполнения, в данном случае closeWeek.

Мы помещаем наши параметры запроса в функцию makeFullRequest и получаем requestId. В строке 13 мы сопоставляем идентификатор запроса с логическим значением, обозначающим статус запроса. Затем мы генерируем событие для сделанного запроса. Наконец, мы отправляем средства, включенные в транзакцию, на кошелек спонсора.

1. Карта в ожидании идентификаторов запроса

В строке 13 мы сохраняем requestId в отображении. Это позволит нам проверить, находится ли запрос на рассмотрении. Давайте добавим следующее под нашими сопоставлениями:

mapping (bytes32 => bool) public pendingRequestIds;

2. Создать событие

В строке 14 мы генерируем событие о том, что запрос был сделан и идентификатор запроса был сгенерирован. События Solidity регистрируются как транзакции в EVM. Нам нужно описать наше событие в верхней части нашего контракта:

contract Lottery is RrpRequesterV0, Ownable {
    event RequestedRandomNumber(bytes32 indexed requestId);

Переписать функцию выполнить

Давайте перезапишем функцию closeWeek:

function closeWeek(bytes32 requestId, bytes calldata data)
  external
  onlyAirnodeRrp // Only AirnodeRrp can call this function
{
  // If valid request, delete the request Id from our mapping
  require(pendingRequestIds[requestId], "No such request made");
  delete pendingRequestIds[requestId]; 
  // Decode data returned by Airnode
  uint256 _randomNumber = abi.decode(data, (uint256)) % MAX_NUMBER;
  // Emit an event that the response has been received
  emit ReceivedRandomNumber(requestId, _randomNumber);
  // Prevent duplicate closings. If someone closed it first it   
  // will increment the end time and throw.
  // require(block.timestamp > endTime, "Lottery is open");
  // The rest we can leave unchanged
  winningNumber[week] = _randomNumber;
  address[] memory winners = tickets[week][_randomNumber];
  week++;
  endTime += 7 days;
  if (winners.length > 0) {
      uint256 earnings = pot / winners.length;
      pot = 0;
      for (uint256 i = 0; i < winners.length; i++) {
          payable(winners[i]).call{value: earnings}(""); 
      }
  }
}

В первой строке установите функцию, которая будет принимать идентификатор запроса и полезную нагрузку (data) в качестве аргументов. В строке 3 мы добавляем модификатор, чтобы ограничить доступ к этой функции только через Airnode RRP. В строках 6 и 7 мы обрабатываем идентификатор запроса. Если идентификатора запроса нет в сопоставлении pendingRequestIds, мы выдаем ошибку, в противном случае мы удаляем идентификатор запроса из сопоставления pendingRequestIds.

В строке 9 мы декодируем и приводим случайное число из полезной нагрузки. Нам не нужно ничего импортировать, чтобы использовать abi.decode(). Затем мы используем оператор модуля (%), чтобы убедиться, что случайное число находится в диапазоне от 0 до MAX_NUMBER.

Строка 14 предотвратит выполнение повторяющихся запросов. Если одновременно делается более 1 запроса, первый из них, который будет выполнен, увеличится на endTime, а остальные вернутся. Мы пока оставим его закомментированным, чтобы упростить тестирование.

В строке 11 мы генерируем событие о том, что получено случайное число. Нам нужно описать наше событие в верхней части нашего контракта под нашим другим событием:

event ReceivedRandomNumber(bytes32 indexed requestId, uint256 randomNumber);

Развертывание каски

Мы будем использовать Hardhat-Deploy для развертывания и управления нашими контрактами в разных цепочках. Сначала установим пакет hardhat-deploy:

1. Установите

npm install -D hardhat-deploy

Затем в верхней части файла hardhat.config.js добавьте следующее:

require("hardhat-deploy");

Теперь мы можем создать папку с именем deploy в нашем корне для размещения всех наших сценариев развертывания. Hardhat-Deploy будет запускать все наши сценарии развертывания по порядку каждый раз, когда мы запускаем npx hardhat deploy.

2. Напишите сценарий развертывания

В нашей папке deploy создайте файл с именем 1_deploy.js. Мы будем использовать Hardhat и пакет протокола Airnode, поэтому давайте импортируем их вверху:

const hre = require("hardhat"); 
const airnodeProtocol = require("@api3/airnode-protocol");

Скрипты развёртывания Hardhat должны выполняться через функцию module.exports. Мы будем использовать пакет протокола Airnode, чтобы получить адрес контракта RRP, необходимый в качестве аргумента для развертывания нашего лотерейного контракта. Мы будем использовать hre.getChainId(), функцию, включенную в Hardhat-Deploy, чтобы получить идентификатор цепочки, для которого мы установили значение 5 в hardhat.config.js.

Наконец, мы развернем контракт, используя hre.deployments. Мы передаем наши аргументы, адрес отправителя и устанавливаем ведение журнала на true.

module.exports = async () => {
  const airnodeRrpAddress =
     airnodeProtocol.AirnodeRrpAddresses[await hre.getChainId()]; 
  
  nextWeek = Math.floor(Date.now() / 1000) + 9000;
  const lotteryContract = await hre.deployments.deploy("Lottery", {
    args: [nextWeek, airnodeRrpAddress], // Constructor arguments
    from: (await getUnnamedAccounts())[0], 
    log: true,
  });
  console.log(`Deployed Contract at ${lotteryContract.address}`);
};

Наконец, давайте назовем наш скрипт внизу файла 1_deploy.js:

module.exports.tags = ["deploy"];

3. Тестируйте локально

Давайте попробуем! Мы должны сначала протестировать на локальном блокчейне, чтобы упростить задачу. Сначала давайте запустим локальный блокчейн. Мы используем флаг --no-deploy, чтобы Hardhat-Deploy не запускал сценарии развертывания каждый раз, когда вы запускаете локальный узел:

npx hardhat node --no-deploy

Затем в отдельном терминале можем развернуть на нашу цепочку (localhost), указанную параметром --network:

npx hardhat --network localhost deploy

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

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

4. Установите спонсорский кошелек при развертывании

Мы можем связать другой сценарий с нашим сценарием развертывания, чтобы функция setSponsorWallet вызывалась после каждого развертывания. Мы начнем с создания файла в папке deploy с именем 2_set_sponsorWallet.js.

В этом скрипте мы будем использовать Административный пакет Airnode. Давайте импортируем их вверху:

const hre = require("hardhat"); 
const airnodeAdmin = require("@api3/airnode-admin");

Теперь давайте создадим нашу функцию module.exports, которая устанавливает кошелек спонсора. Сначала мы будем использовать эфиры, чтобы получить кошелек. Мы можем использовать hre.deployments.get для получения прошлых развертываний благодаря Hardhat-Deploy. Затем мы создаем экземпляр нашего развернутого контракта в нашем скрипте. Наш развернутый контракт готов к взаимодействию!

Давайте создадим наш спонсорский кошелек, чтобы мы могли передать его в нашу функцию setSponsorWallet. Мы будем следовать тем же шагам, которые мы использовали для настройки спонсорского кошелька в наших тестах ранее. Мы жестко закодируем адрес xpub и ANU Airnode в строках 11 и 12.

Теперь давайте добавим тег Hardhat-Deploy в наш скрипт, чтобы он запускался после каждого развертывания:

module.exports.tags = ["setup"];

Давайте попробуем!

npx hardhat --network localhost deploy

Живое тестирование!

На этом этапе мы будем тестировать наш контракт, развернув его в действующей блокчейне тестовой сети, что позволит другим взаимодействовать с нашим контрактом. Это позволит ANU QRNG Airnode отвечать на наши запросы случайных чисел.

1. Введите скрипт

Нам нужно написать скрипт, который будет подключаться к нашему развернутому контракту и участвовать в лотерее. Большая часть кода на этих шагах будет похожа на тесты, которые мы написали ранее. Мы начнем с создания файла в папке scripts с именем enter.js. Если вы заглянете внутрь стандартного файла deploy.js, вы увидите, что Hardhat рекомендует формат для скриптов:

const hre = require("hardhat"); 
async function main() {
  // Your script logic here...
}
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Внутри функции main мы можем поместить наш скрипт 'enter':

const [account] = await hre.ethers.getSigners();
// Get "Lottery" contract deployed via Hardhat-Deploy and connect
// the account to it
const Lottery = await hre.deployments.get("Lottery");
const lotteryContract = new hre.ethers.Contract(
  Lottery.address,
  Lottery.abi,
  account
);
const ticketPrice = await lotteryContract.ticketPrice(); 
const guess = 55; // The number we choose for our lottery entry
const tx = await lotteryContract.enter(
  guess, 
  { value: ticketPrice } // Include the ticket price
);
await tx.wait(); // Wait for the transaction to be mined
const entries = await lotteryContract.getEntriesForNumber(guess, 1); 
console.log(`Guesses for ${guess}: ${entries}`);

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

npx hardhat --network localhost run scripts/enter.js

2. Закрыть скрипт лотереи

Затем нам нужен способ, чтобы люди запускали Airnode для случайного числа, когда лотерея может быть закрыта. Мы начнем с создания файла в папке scripts с именем close.js и добавления в него стандартного кода скрипта из последнего шага.

В нашей функции main мы снова создадим экземпляр нашего контракта. Мы будем вызывать функцию getWinningNumber в нашем контракте, чтобы инициировать запрос случайного числа. Эта функция генерирует событие, которое мы можем прослушивать для нашего идентификатора запроса, который мы будем использовать для прослушивания ответа.

Когда мы услышим ответ, мы можем позвонить по номеру winningNumber(1), чтобы получить выигрышное случайное число для первой недели!

const Lottery = await hre.deployments.get("Lottery");
const lotteryContract = new hre.ethers.Contract(
  Lottery.address,
  Lottery.abi,
  (await hre.ethers.getSigners())[0]
);
console.log("Making request for random number...");
// Call function and use the tx receipt to parse the requestId
const receipt = await lotteryContract.getWinningNumber({
  value: ethers.utils.parseEther("0.01"),
});
// Retrieve request ID from receipt
const requestId = await new Promise((resolve) =>
  hre.ethers.provider.once(receipt.hash, (tx) => {
    const log = tx.logs.find((log) => log.address ===
        lotteryContract.address);
    const parsedLog = lotteryContract.interface.parseLog(log);
    resolve(parsedLog.args.requestId);
  })
);
console.log(`Request made! Request ID: ${requestId}`);
// Wait for the fulfillment transaction to be confirmed and read 
// the logs to get the random number
await new Promise((resolve) =>
  hre.ethers.provider.once(
    lotteryContract.filters.ReceivedRandomNumber(requestId, null),
    resolve
  )
);
const winningNumber = await lotteryContract.winningNumber(1);
console.log("Fulfillment is confirmed!");
console.log(winningNumber.toString());

Если мы проверим это на нашей локальной цепочке, мы должны получить идентификатор запроса, но не получить ответа. Это связано с тем, что ANU Airnode не может получить доступ к запросам в нашей локальной сети.

npx hardhat --network localhost run scripts/close.js

Вы можете завершить процесс запроса после того, как будет напечатан идентификатор запроса.

3. Настройте Гёрли

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

⚠️ Никогда не используйте кошелек с реальными средствами для развития! ⚠️

Во-первых, давайте сгенерируем кошелек. Мы будем использовать Airnode Admin CLI для создания мнемоники, но не стесняйтесь создавать кошелек любым удобным для вас способом.

npx @api3/airnode-admin generate-mnemonic
# Output
This mnemonic is created locally on your machine using "ethers.Wallet.createRandom" under the hood.
Make sure to back it up securely, e.g., by writing it down on a piece of paper:
genius session popular ... # Our mnemonic
# The public address to our wallet
The Airnode address for this mnemonic is: 0x1a942424D880... 
# The Xpub of our wallet
The Airnode xpub for this mnemonic is: xpub6BmYykrmWHAhSFk...

Мы будем использовать мнемонику и адрес Airnode (публичный адрес). Давайте добавим нашу мнемонику в файл .env, чтобы мы могли безопасно ее использовать:

MNEMONIC="genius session popular ..."

Далее мы настроим Hardhat для использования сети Goerli и нашей мнемоники. Внутри объекта networks в нашем файле hardhat.config.js измените module.exports, чтобы добавить сетевую запись:

module.exports = {
  solidity: "0.8.9",
  networks: {
    hardhat: { 
      chainId: 5, 
      forking: {
        url: process.env.RPC_URL,
      }
    },
    goerli: {
      url: process.env.RPC_URL, // Reuse our Goerli RPC URL
      accounts: { mnemonic: process.env.MNEMONIC } // Our mnemonic
    }
  }
};

Теперь мы можем запускать все наши команды с добавленным флагом --network goerli без необходимости изменения кода!

4. Получите Goerli ETH

Если вы пытались запустить какие-либо команды против Гёрли, скорее всего, они потерпели неудачу. Это потому, что мы используем наш недавно созданный кошелек, в котором нет средств для оплаты транзакции. Мы можем получить немного бесплатного ETH Goerli для тестирования, используя сборщик Goerli. Для этого крана требуется учетная запись Alchemy, а для этого крана требуется учетная запись Twitter или Facebook.

Мы вставим публичный адрес (не мнемонический!) из нашего шага создания кошелька в один или оба крана:

Мы можем протестировать наши аккаунты в Hardhat с помощью задач. Внутри файла hardhat.config.js под нашим импортом и над нашим экспортом добавьте следующее:

task(
  "balance",
  "Prints the balance of the first account",
  async (taskArgs, hre) => {
    const [account] = await hre.ethers.getSigners();
    const balance = await account.getBalance(); 
    console.log(
    `${account.address}: (${hre.ethers.utils.formatEther(balance)} ETH)`);
  }
);

Теперь мы можем запустить задачу balance и посмотреть баланс нашего счета:

npx hardhat --network goerli balance

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

0x0EDA9399c969...: (0.5 ETH)

5. Используйте лотерейный контракт в публичной сети

У нас все настроено для развертывания в общедоступной сети. Начнем с команды развертывания:

npx hardhat --network goerli deploy

Имейте в виду, что в сети Goerli все будет происходить намного медленнее.

Далее мы войдем в нашу лотерею:

npx hardhat --network goerli run ./scripts/enter.js

И, наконец, закрываем нашу лотерею:

npx hardhat --network goerli run ./scripts/close.js

Заключение

Это конец урока, надеюсь, вы узнали что-то новое! Полный код можно найти в туториальном репозитории (не стесняйтесь поставить ⭐️). Если у вас есть какие-либо вопросы, пожалуйста, присоединяйтесь к API3 Discord.