Многим из вас, наверное, известно, что в теории, смарт-контракты в 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-upgradeable \
@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, upgrades } = 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')
}
// Видно, что на обоих проксях новое поведение.
});
});
На сегодня все, а уже скоро разберем как можно деплоить прокси-контракты из другого контракта!
P.S. у нас есть небольшой междусобойчик по web3 разработке, можете заглянуть :) https://discord.gg/EBQXZQP6xV