Разбираемся с BeaconProxy от OpenZeppelin
Многим из вас, наверное, известно, что в теории, смарт-контракты в EVM-подобных системах, являются неизменяемыми (immutable), но на практике это уже давно не так. И речь даже не о таких свойствах как Pausable, то есть каких-то переменных состояния контракта, которые могут влиять на его работоспособность, а о более серьезных возможностях изменения бизнес-логики контракта. В этой статье я опишу основные приемы и остановлюсь подробнее на одном из них, на BeaconProxy.
Ключом к пониманию механизмов обновления контрактов являются следующие утверждения:
- В вашем Solidity-контракте под капотом одна точка входа. Да-да! Вы описываете много функций, и думаете, что они вызываются, и они вызываются, но только вот точка входа в контракт одна и в ней находится роутер, считывающий из входной транзакции хэш названия функции, и делающий JUMPI на соответствующую позицию в скомпилированном коде. (ссылка на источник).
- Если переданный хэш не соответствует ни одной известной роутеру функции, исполняется метод fallback (если он есть).
- Метод delegatecall позволяет вызывать точку входа другого контракта, при этом используя слоты хранилища от текущего контракта. Другими словами, сами инструкции виртуальной машины EVM выглядят так: прочитай из хранилища слот 3, запиши в слот 8 хранилища число 12, итп. По умолчанию, при обычном вызове контракта, в качестве хранилища используется хранилище, ассоциированное с самим контрактом. Само по себе хранилище ничего не знает о контракте, это просто key/value интерфейс, где key - это номер слота. Вся работа с ним осуществляется из самого контракта.
Вышеизложенного, на мой взгляд, достаточно чтобы понять как построена система обновлений контрактов. Для взаимодействия с пользователем деплоится так называемый Proxy-контракт. Он хранит в одном из слотов (с высоким номером) адрес контракта код которого надо выполнить. При вызове прокси, срабарывает fallback, а оттуда вызывается delegatecall. При обновлении контракта деплоится новая логика, отдельно, в новый неизменяемый контракт, а затем адрес нового контракта сохраняется в указатель внутри Proxy-контракта (источник).
На этом теория закончилась, давайте работать руками! Подготовим окружение:
# Подготовка к работе
$ curl -s https://deb.nodesource.com/setup_16.x | sudo bash
$ sudo apt-get install -y nodejs
$ mkdir habr-proxy && cd habr-proxy
$ npm init -y
$ npm install --save-dev @openzeppelin/contracts-upgradable \
@openzeppelin/hardhat-upgrades \
@nomiclabs/hardhat-ethers ethers
$ npx hardhat # тут я выбираю basic project
$ nano hardhat.config.js # добавить require('@openzeppelin/hardhat-upgrades');И попробуем сделать что-то такое:
Я подготовил вот такие простые контракты:
//
// contracts/Version1.sol
//
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Version1 is Initializable {
uint32 public counter;
function __Version1_init() internal onlyInitializing {
__Version1_init_unchained();
}
function __Version1_init_unchained() internal onlyInitializing {
counter = 100;
}
function initialize() initializer public {
__Version1_init();
}
function setCounter(uint32 counter_) public {
counter = counter_;
}
function getCounter() view public returns(uint32) {
return counter;
}
/**
* @dev This empty reserved space is put in place to allow future versions to add new
* variables without shifting down storage in the inheritance chain.
* See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
*/
uint256[45] private __gap;
}
//
// contracts/Version2.sol
//
pragma solidity ^0.8.0;
import "hardhat/console.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Version2 is Initializable {
uint32 public counter;
function __Version2_init() internal onlyInitializing {
__Version2_init_unchained();
}
function __Version2_init_unchained() internal onlyInitializing {
counter = 1000;
}
function initialize() initializer public {
__Version2_init();
}
function setCounter(uint32 counter_) public {
counter = counter_+500;
}
function getCounter() view public returns(uint32) {
return counter+5;
}
/**
* @dev This empty reserved space is put in place to allow future versions to add new
* variables without shifting down storage in the inheritance chain.
* See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
*/
uint256[45] private __gap;
}Контракты совсем простые + следуют рекомендациям OpenZeppelin о подготовке Upgradable контрактов. Теперь напишем тест, чтобы проверить что наше понимание документации соответствует действительности.
// test/main-test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("BeaconProxy", function () {
it("Should do what we need when deployed from Hardhat", async function () {
const Version1 = await ethers.getContractFactory("Version1");
const Version2 = await ethers.getContractFactory("Version2");
// Развертывание контракта, который хранит указатель на актуальную
// версию контракта, (Implementation Pointer на диаграмме)
const beacon = await upgrades.deployBeacon(Version1);
await beacon.deployed();
console.log("Beacon deployed to:", beacon.address);
// Развертывание проксей (и, соответственно, стораджей)
const proxy1 = await upgrades.deployBeaconProxy(beacon, Version1, []);
await proxy1.deployed();
console.log("Proxy1 deployed to:", proxy1.address);
const proxy2 = await upgrades.deployBeaconProxy(beacon, Version1, []);
await proxy2.deployed();
console.log("Proxy2 deployed to:", proxy2.address);
// Переменные для отправки запросов через с прокси.
const proxy1_accessor = Version1.attach(proxy1.address)
const proxy2_accessor = Version1.attach(proxy2.address)
// И вот начались наши тесты.
{
const setValueTx = await proxy1_accessor.setCounter(105)
await setValueTx.wait()
}
{
const value = await proxy1_accessor.getCounter()
expect(value.toString()).to.equal('105')
}
{
const value = await proxy2_accessor.getCounter()
expect(value.toString()).to.equal('100')
}
// Как мы видим, данные хранятся и правда внутри прокси-контрактов,
// поэтому в одном переменная равна 105, а в другом 100.
// Производим обновление указателя на версию контракта.
await upgrades.upgradeBeacon(beacon, Version2);
{
const setValueTx = await proxy1_accessor.setCounter(105)
await setValueTx.wait()
}
{
const value = await proxy1_accessor.getCounter()
expect(value.toString()).to.equal('610') // 105 + 500 + 5
}
{
const value = await proxy2_accessor.getCounter()
expect(value.toString()).to.equal('105')
}
// Видно, что на обоих проксях новое поведение.
});
});На сегодня все, а уже скоро разберем как можно деплоить прокси-контракты из другого контракта!
Автор статьи: @stromsund. А ещё его можно найти тут