Проект PetShop, день 5: Понимание схемы хранения контрактов и шаблона обновления прокси-сервера

Я собираюсь снова обновить PetShop NFT, чтобы внести следующие изменения:

  • Установить лимит предложения токенов и прекратить майнинг новых токенов, если лимит достигнут.
  • Установить цену за майнинг токена. Эфиры, взимаемые за майнинг токенов, будут депонироваться на адрес контракта.
  • Разрешить владельцу контракта вывести эфиры из контракта.

Первая попытка (которая не удалась)

Моя первая попытка — создать файл PetShopV3.sol следующим образом:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.16;

import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/security/PullPaymentUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/CountersUpgradeable.sol";
import "hardhat/console.sol";

contract PetShopV3 is ERC721URIStorageUpgradeable, PullPaymentUpgradeable, OwnableUpgradeable {
    using CountersUpgradeable for CountersUpgradeable.Counter;
    CountersUpgradeable.Counter private tokenIds;

    uint8 private constant VERSION = 3;
    uint256 public constant TOTAL_SUPPLY = 10_000;
    uint256 public constant MINT_PRICE = 0.08 ether;

    function initializeV3() reinitializer(VERSION) public {
        console.log("Initializing PetShop version %s...", VERSION);
        __PullPayment_init_unchained();
        __Ownable_init_unchained();
     }

    function mintToken(string calldata _tokenURI, address _to) external payable returns (uint256) {
        uint256 lastTokenId = tokenIds.current();
        require(lastTokenId < TOTAL_SUPPLY, "Max supply reached");
        require(msg.value == MINT_PRICE, "Transaction value did not equal the mint price");

        tokenIds.increment();
        uint256 newTokenId = tokenIds.current();
        _safeMint(_to, newTokenId);
        _setTokenURI(newTokenId, _tokenURI);
        return newTokenId;
    }

    function withdrawPayments(address payable payee) public virtual override onlyOwner {
        super.withdrawPayments(payee);
    }

    function withdraw() external onlyOwner {
        _asyncTransfer(msg.sender, address(this).balance);
        withdrawPayments(payable(msg.sender));
    }

    function version() external pure returns (uint256) {
        return VERSION;
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Некоторые замечания по поводу этой реализации:

  • Я использую две константы, TOTAL_SUPPLY и MINT_PRICE, для определения общего количества токенов и цены за чеканку токена. Я использую require(), чтобы убедиться, что эти условия выполнены. Если нет, require() выдаст ошибку, что приведет к отмене транзакции.
  • Я использую PullPaymentUpgradeable от OpenZeppelin для внедрения безопасного метода withdrawPayments() в мой PetShop NFT. Этот метод реализует простую стратегию pull-payment, которая часто считается лучшей практикой.
  • Я добавляю новый метод withdraw() для вывода всех средств из контракта владельцу. Я использую модификатор onlyOwner (из OpenZeppelin’s OwnableUpgradeable) для ограничения доступа к методам вывода средств, чтобы их мог вызывать только владелец контракта.

Теперь обновите код фикстуры нашего теста:

async function deployPetShopFixture() {
  const PetShopV1 = await ethers.getContractFactory("PetShop");
  const petShopV1 = await upgrades.deployProxy(PetShopV1);
  await petShopV1.deployed();
  const proxyAddress = petShopV1.address;

  console.log("Upgrading PetShop to version 2...");
  const PetShopV2 = await ethers.getContractFactory("PetShopV2");
  const petShopV2 = await upgrades.upgradeProxy(proxyAddress, PetShopV2);
  await petShopV2.initializeV2();

  console.log("Upgrading PetShop to version 3...");
  const PetShopV3 = await ethers.getContractFactory("PetShopV3");
  const petShopV3 = await upgrades.upgradeProxy(proxyAddress, PetShopV3);
  await petShopV3.initializeV3();

  const accounts = await ethers.getSigners();

  return {
    PetShop: PetShopV3,
    petShop: petShopV3,
    accounts: accounts,
  };
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

$ npx hardhat test
...
  1) PetShop contract
       Deployment
         should initialize the NFT name and symbol:
     Error: New storage layout is incompatible

@openzeppelin/contracts-upgradeable/security/PullPaymentUpgradeable.sol:30: Inserted `_escrow`
  > New variables should be placed after all existing inherited variables
      ...
Войти в полноэкранный режим Выход из полноэкранного режима

Чтобы понять ошибку, нам нужно понять две вещи:

  • Схема хранения контрактов
  • Шаблон обновления прокси

Схема хранения контрактов

Смарт-контракт, развернутый на блокчейне, состоит из двух частей:

  • Бизнес-логика, представленная в виде байткода EVM, скомпилированного из исходного кода Solidity.
  • Состояние контракта, представленное в виде переменных состояния контракта и записанное в постоянное хранилище контракта.

Можно считать, что каждый контракт имеет выделенное пространство хранения, которое представляет собой огромное разреженное отображение из uint256 в 32-байтовый слот. Ключ, который имеет тип uint256, называется номером слота. Значение — это свободное пространство длиной 32 байта, которое может быть использовано для хранения любых данных. Контракт будет помещать свои переменные состояния в это отображение, следуя некоторым правилам, определенным в спецификации языка Solidity. Ключевыми моментами являются:

  • Начиная с хранилища solot 0, переменные состояния будут храниться в порядке их объявления, упакованные в соответствии с некоторыми правилами.
  • Начиная с solot 0, каждый тип переменных будет занимать фиксированное количество типов. Для отображений и типов массивов динамического размера, размер которых непредсказуем, будут занимать всего 32 байта с учетом правил, описанных выше, а элементы, которые они содержат, хранятся, начиная с другого слота хранения, который вычисляется с помощью хэша Keccak-256.

Таким образом, пространство хранения во многом похоже на пространство памяти программы на C++ или Java: Оно разделено на две части: стек и куча. Стек используется для статического распределения. В стеке переменные упаковываются и выкладываются в порядке их объявления, начиная со слота 0. Куча используется для динамического распределения (отображения и динамические массивы, такие как строки и байты). Вместо последовательного распределения, слоты в куче распределяются прерывисто, где номер слота обычно вычисляется из некоторого хэша Keccak-256.

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

Другое важное правило определено для наследования контрактов:

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

Вернемся к нашему контракту PetShop. В версии 2 наш контракт имеет один базовый контракт, ERC721URIStorageUpgradeable, и содержит одну единственную переменную состояния, tokenIds, которая является счетчиком uint256. Предположим, что ERC721URIStorageUpgradeable нуждается в N слотах для своих переменных состояния. Схема стека хранения нашего зоомагазина будет выглядеть следующим образом:

Ключ: Номер слота Значение: Содержание слота
0 ~ N — 1 Переменные состояния из ERC721URIStorageUpgradeable
N

В PetShopV3 мы добавили два базовых контракта: PullPaymentUpgradeable и OwnableUpgradeable. Предположим, что им нужны слоты M и P соответственно для их переменных состояния. Теперь схема стека будет выглядеть следующим образом:

Ключ: Номер слота Значение: Содержание слота
0 ~ N — 1 Переменные состояния из ERC721URIStorageUpgradeable
N ~ N + M — 1 Переменные состояния из PullPaymentUpgradeable
N + M ~ N + M + P — 1 Переменные состояния из OwnableUpgradeable
N + M + P

Мы видим, что для обновления до версии 3 PetShop изменится схема хранения нашего контракта.

Шаблон обновления прокси

Чтобы понять, почему это запрещено, нам нужно понять схему обновления прокси, используемую OpenZeppelin.

Вот ключевой момент:

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

Решение

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

Ключ: Номер слота Значение: Содержание слота
0 ~ N — 1 Переменные состояния из ERC721URIStorageUpgradeable
N
N + 1 ~ N + M Переменные состояния из PullPaymentUpgradeable
N + M + 1 ~ N + M + P Переменные состояния из OwnableUpgradeable

И вот наш PetShopV3.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.16;

import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/security/PullPaymentUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/CountersUpgradeable.sol";

/// @dev This base class duplicates all state variables of `PetShopV2`.
abstract contract PetShopBaseV3 is ERC721URIStorageUpgradeable {
    using CountersUpgradeable for CountersUpgradeable.Counter;
    CountersUpgradeable.Counter internal tokenIds;
}

contract PetShopV3 is PetShopBaseV3, PullPaymentUpgradeable, OwnableUpgradeable {
    // NOTE: The `using` directive is not inherited. In order to call methods on `tokenIds`,
    // we will need to repeat the `using` directive here.
    using CountersUpgradeable for CountersUpgradeable.Counter;

    uint8 private constant VERSION = 3;
    uint256 public constant TOTAL_SUPPLY = 10_000;
    uint256 public constant MINT_PRICE = 0.08 ether;

    function initializeV3() reinitializer(VERSION) public {
        console.log("Initializing PetShop version %s...", VERSION);
        __PullPayment_init_unchained();
        __Ownable_init_unchained();
    }

    function mintToken(string calldata _tokenURI, address _to) external payable returns (uint256) {
        uint256 lastTokenId = tokenIds.current();
        require(lastTokenId < TOTAL_SUPPLY, "Max supply reached");
        require(msg.value == MINT_PRICE, "Transaction value did not equal the mint price");

        tokenIds.increment();
        uint256 newTokenId = tokenIds.current();
        _safeMint(_to, newTokenId);
        _setTokenURI(newTokenId, _tokenURI);
        return newTokenId;
    }

    function withdrawPayments(address payable payee) public virtual override onlyOwner {
        super.withdrawPayments(payee);
    }

    function version() external pure returns (uint8) {
        return VERSION;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Идея заключается в следующем:

  • Мы вводим базовый контракт PetShopBaseV3, чтобы повторить расположение хранилища в нашей предыдущей версии PetShop.
  • Мы делаем этот базовый контракт самым первым базовым контрактом нашего PetShopV3.
  • Мы объявляем PullPaymentUpgradeable и OwnableUpgradeable в качестве второго и третьего базовых контрактов.

Таким образом, нам удалось сохранить совместимость схемы хранения.

Повторно запустите тест. Теперь мы получим ошибку, как и ожидалось:

$ npx hardhat test

  PetShop contract
    Deployment
Upgrading PetShop to version 2...
Upgrading PetShop to version 3...
      ✔ should initialize the NFT name and symbol (1910ms)
      ✔ should upgrade proxy to version 3
    Transactions
      1) should mint NFTs

  2 passing (2s)
  1 failing

  1) PetShop contract
       Transactions
         should mint NFTs:
     Error: VM Exception while processing transaction: reverted with reason string 'Transaction value did not equal the mint price'
    at PetShopV3.mintToken (contracts/PetShopV3.sol:30)
    ...
Войдите в полноэкранный режим Выход из полноэкранного режима

Обновление теста PetShop

Исправить наш тест очень просто. Мы также проверим это:

  • После успешной майнинга некоторых токенов, эфиры, отправленные контракту, должны быть зачислены на его баланс.
  • Отправка отсутствия эфиров для майнинга токена должна завершиться неудачей.
  • Отправка недостаточного количества эфира для майнинга токена должна завершиться неудачей.
  • Отправка слишком большого количества эфира для майнинга токена должна завершиться неудачей.
describe("Transactions", function() {
  it("should mint NFTs if value sent equal mint price", async function() {
    const { petShop, accounts } = await loadFixture(deployPetShopFixture);

    const someAccounts = accounts.slice(1, 4);
    for (let i = 0; i < someAccounts.length; i++) {
      const account = someAccounts[i];
      const tokenID = i + 1; // Token ID should start from 1.
      const tokenURI = `https://petshop.example/nft/${tokenID}`;
      await expect(
        petShop.connect(account).mintToken(tokenURI, account.address, {
          value: ethers.utils.parseEther("0.08"),
        })
      ).to.emit(petShop, "Transfer").withArgs(ZERO_ADDRESS, account.address, tokenID);
      expect(await petShop.tokenURI(tokenID)).to.equal(tokenURI);
      expect(await petShop.ownerOf(tokenID)).to.equal(account.address);
      expect(await petShop.balanceOf(account.address)).to.equal(1);
    }

    expect(await petShop.balanceOf(accounts[0].address)).to.equal(0);

    const actualBalance = await ethers.provider.getBalance(petShop.address);
    const expectedBalance = ethers.utils.parseEther("0.24");
    expect(actualBalance).to.equal(expectedBalance);
  });

  it("should revert if send no ether", async function() {
    const { petShop, accounts } = await loadFixture(deployPetShopFixture);
    const account = accounts[0];
    const tokenURI = "https://petshop.example/nft/foo";
    await expect(
      petShop.connect(account).mintToken(tokenURI, account.address)
    ).to.be.revertedWith("Transaction value did not equal the mint price");
    expect(await petShop.balanceOf(account.address)).to.equal(0);
    expect(await ethers.provider.getBalance(petShop.address)).to.equal(0);
  });

  it("should revert if send less ether", async function() {
    const { petShop, accounts } = await loadFixture(deployPetShopFixture);
    const account = accounts[0];
    const tokenURI = "https://petshop.example/nft/foo";
    await expect(
      petShop.connect(account).mintToken(tokenURI, account.address, {
        value: ethers.utils.parseEther("0.01"),
      })
    ).to.be.revertedWith("Transaction value did not equal the mint price");
    expect(await petShop.balanceOf(account.address)).to.equal(0);
    expect(await ethers.provider.getBalance(petShop.address)).to.equal(0);
  });

  it("should revert if send to much ether", async function() {
    const { petShop, accounts } = await loadFixture(deployPetShopFixture);
    const account = accounts[0];
    const tokenURI = "https://petshop.example/nft/foo";
    await expect(
      petShop.connect(account).mintToken(tokenURI, account.address, {
        value: ethers.utils.parseEther("0.99"),
      })
    ).to.be.revertedWith("Transaction value did not equal the mint price");
    expect(await petShop.balanceOf(account.address)).to.equal(0);
    expect(await ethers.provider.getBalance(petShop.address)).to.equal(0);
  });

});
Вход в полноэкранный режим Выход из полноэкранного режима

Создание задачи для обновления PetShop NFT

Теперь мы создадим новую задачу для обновления PetShop NFT до версии 3:

task("petshop-upgrade-v3", "Upgrades PetShop NFT to version 3")
  .addParam("address", "The contract address")
  .setAction(async (taskArgs) => {
    const [deployer] = await ethers.getSigners();
    console.log(`Deployer: ${deployer.address} (balance: ${await deployer.getBalance()})`);

    const proxyAddress = taskArgs.address;

    // Verify the current version.
    const petShop = await loadNFTContract("PetShopV2", proxyAddress);
    const currentVersion = await petShop.version();
    if (currentVersion !== 2) {
      throw new Error(`Current version should be 2 but got ${currentVersion}`);
    }

    // Upgrade to next version.
    console.log(`Upgrading proxy contract (${proxyAddress}) to version 3...`);
    const PetShopV3 = await ethers.getContractFactory("PetShopV3");
    const petShopV3 = await upgrades.upgradeProxy(proxyAddress, PetShopV3);
    await petShopV3.initializeV3();
    console.assert(petShopV3.address === proxyAddress, "Proxy contract address should not change.");

    const name = await petShopV3.name();
    const symbol = await petShopV3.symbol();
    const version = await petShopV3.version();
    console.log(`Upgraded contract ${name} (symbol: ${symbol}) to version ${version}.`);
  });
Войти в полноэкранный режим Выйти из полноэкранного режима

Запустите локальный сетевой демон Hardhat и попробуйте развернуть и обновить контракт:

$ npx hardhat petshop-deploy --network localhost
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (balance: 10000000000000000000000)
Deployed PetShop at: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
Querying NFT: name = Pet Shop; symbol = PET

$ npx hardhat petshop-upgrade-v2 --network localhost 
    --address 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (balance: 9999996833903902260960)
Upgrading proxy contract (0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0) to version 2...
Upgraded contract Pet Shop (symbol: PET) to version 2.

$ npx hardhat petshop-upgrade-v3 --network localhost 
    --address 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (balance: 9999995224069230583548)
Loaded NFT contract PetShopV2 from 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0: Pet Shop (PET)
Upgrading proxy contract (0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0) to version 3...
Upgraded contract Pet Shop (symbol: PET) to version 3.
Войти в полноэкранный режим Выйти из полноэкранного режима

Обновление PetShop NFT в тестовой сети Goerli

В тестовой сети Goerli наш контракт уже имеет версию 2. Попробуйте обновить его до версии 3:

$ npx hardhat petshop-upgrade-v3 --network goerli 
    --address 0xff27228e6871eaB08CD0a14C8098191279040c13
Deployer: 0xCc4c8184CC4A5A03babC13D832cEE3E41bE92d08 (balance: 725010088175111518)
Loaded NFT contract PetShopV2 from 0xff27228e6871eaB08CD0a14C8098191279040c13: Pet Shop (PET)
Upgrading proxy contract (0xff27228e6871eaB08CD0a14C8098191279040c13) to version 3...
Upgraded contract Pet Shop (symbol: PET) to version 3.
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Теперь наш PetShop версии 3 работает в Goerli testnet. Если мы попытаемся майнить новый токен, это не удастся, потому что наша задача petshop-mint не отправляет эфир:

$ npx hardhat petshop-mint --network goerli 
    --address 0xff27228e6871eaB08CD0a14C8098191279040c13 
    --to      0xCc4c8184CC4A5A03babC13D832cEE3E41bE92d08 
    --uri     https://petshop.example/nft/foo
Loaded contract from 0xff27228e6871eaB08CD0a14C8098191279040c13: Pet Shop (PET)
  * Sending tx...
An unexpected error occurred:

Error: cannot estimate gas; transaction may fail or may require manual gas limit ...
(reason="execution reverted: Transaction value did not equal the mint price", method="estimateGas", ...)
    at Logger.makeError ...
    ... {
  reason: 'execution reverted: Transaction value did not equal the mint price',
  code: 'UNPREDICTABLE_GAS_LIMIT',
  method: 'estimateGas',
  ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

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