Что такое обновляемые смарт-контракты?
Обычно, когда мы развертываем смарт-контракт, невозможно обновить или изменить код, поскольку он находится на цепи, и так и должно быть. Это повышает безопасность и доверие пользователей, которые взаимодействуют с этим контрактом.
Но могут быть случаи, когда вы хотите обновить свой смарт-контракт, например, исправить серьезные ошибки или добавить некоторые критически важные функции для пользователей. Традиционно сделать это невозможно. Максимум, что можно сделать, — это создать новый смарт-контракт с исправлением ошибок и переносом всей информации. На этом работа не заканчивается: необходимо обновить ссылки, где использовался адрес старого контракта, и проинформировать существующих пользователей об использовании нового контракта.
Это может оказаться непосильной задачей, но есть способ справиться с такими случаями с помощью обновляемых контрактов. Обновляемый смарт-контракт можно обновлять и редактировать после развертывания. Этого можно достичь с помощью плагина/инструмента, созданного OpenZeppelin.
В двух словах
Плагин используется для развертывания контрактов на Hardhat или truffle. Если в будущем вы захотите обновить смарт-контракт, используйте тот же адрес, который вы использовали для развертывания первого контракта с помощью плагина, и плагин обработает передачу любого состояния и данных из старого контракта, сохраняя тот же адрес контракта для взаимодействия.
OpenZeppelin использует модель прокси, где они развертывают три смарт-контракта для управления уровнем хранения и реализации смарт-контрактов. Каждый раз, когда вызывается контракт, пользователь косвенно вызывает прокси-контракт, а прокси-контракт передает параметры реализованному смарт-контракту, прежде чем отправить результат обратно пользователю. Теперь, поскольку у нас есть прокси-контракт в качестве посредника, представьте, что вы хотите изменить что-то в своем реализованном контракте. Все, что вам нужно сделать, это развернуть новый контракт и сказать своему прокси-контракту ссылаться на последний смарт-контракт, и вуаля. Все пользователи используют обновленный контракт.
Как работают обновляемые контракты?
Когда мы используем обновляемый плагин OpenZeppelin для развертывания контракта, развертываются три контракта —
- Внедренный контракт — контракт, который создают разработчики и который содержит всю логику и функциональные возможности.
- Прокси-контракт — контракт, с которым взаимодействует конечный пользователь. Все данные и состояние контракта хранятся в контексте прокси-контракта. Этот прокси-контракт является реализацией стандарта EIP1967.
- Контракт ProxyAdmin — Этот контракт связывает контракт Proxy и контракт реализации.
Что такое ProxyAdmin? (Согласно документации OpenZeppelin)
ProxyAdmin — это контракт, который действует как владелец всех ваших прокси. В каждой сети развертывается только один. Когда вы запускаете свой проект, ProxyAdmin принадлежит адресу deployer, но вы можете передать право собственности на него, вызвав transferOwnership.
Когда пользователь вызывает контракт прокси, вызов делегируется контракту реализации. Теперь, чтобы обновить контракт, нам нужно сделать следующее:
- Развернуть обновленный контракт реализации.
- Обновление ProxyAdmin таким образом, чтобы все вызовы перенаправлялись на новый внедренный контракт.
OpenZeppelin создал плагин для Hardhat и Truffle, чтобы справиться с этими задачами за нас. Давайте рассмотрим шаг за шагом, как создавать и тестировать обновляемые контракты.
Написание обновляемых смарт-контрактов
Мы будем использовать локальный хост Hardhat для локального тестирования контракта и тестовую сеть Celo Alfajores.
Инициализируйте проект и установите зависимости
mkdir upgradeable-contract && cd upgradeable-contract
yarn init -y
yarn add hardhat
yarn hardhat // choose typescript
Добавьте плагин и обновите hardhat.config.ts
yarn add @openzeppelin/hardhat-upgrades
// hardhat.config.ts
import '@openzeppelin/hardhat-upgrades';
Для понимания процесса мы будем использовать контракт Greeter.sol
. Мы создадим и развернем несколько версий этого контракта.
- Greeter.sol
- GreeterV2.sol
- GreeterV3.sol
ПРИМЕЧАНИЕ — Большая разница между обычным контрактом и обновляемым смарт-контрактом заключается в том, что обновляемые смарт-контракты не имеют конструктора.
//contracts/Greeter.sol
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
contract Greeter {
string public greeting;
// Emitted when the stored value changes
event ValueChanged(string newValue);
function greet() public view returns (string memory) {
return greeting;
}
function setGreeting(string memory _greeting) public {
greeting = _greeting;
emit ValueChanged(_greeting);
}
}
Это очень простой контракт Greeter, который возвращает значение greeting
всякий раз, когда мы вызываем метод greet()
.
Юнит-тестирование для Greeter.sol
Создайте файл с именем 1.Greeter.test.ts
и добавьте в него следующее содержимое.
// test/1.Greeter.test.ts
import { expect } from "chai";
import { Contract } from "ethers";
import { ethers } from "hardhat";
describe("Greeter", function () {
let greeter: Contract;
beforeEach(async function () {
const Greeter = await ethers.getContractFactory("Greeter");
greeter = await Greeter.deploy();
await greeter.deployed();
});
it("should greet correctly before and after changing value", async function () {
await greeter.setGreeting("Celo to the Moon");
expect(await greeter.greet()).to.equal("Celo to the Moon");
});
});
Запустите тест:
yarn hardhat test test/1.Greeter.test.ts
Результаты:
Greeter
✔ should greet correctly before and after changing value
1 passing (334ms)
✨ Done in 1.73s.
Давайте напишем сценарий развертывания и развернем Greeter.sol
на локальном узле Hardhat. Создайте файл с именем Greeter.deploy.ts
в каталоге scripts
и вставьте в него следующий код.
// scripts/Greeter.deploy.ts
import { ethers, upgrades } from "hardhat";
async function main() {
const Greeter = await ethers.getContractFactory("Greeter");
console.log("Deploying Greeter...");
const greeter = await upgrades.deployProxy(Greeter);
console.log(greeter.address, " greeter(proxy) address");
console.log(
await upgrades.erc1967.getImplementationAddress(greeter.address),
" getImplementationAddress"
);
console.log(
await upgrades.erc1967.getAdminAddress(greeter.address),
" getAdminAddress"
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Запустите локальный узел и разверните контракт Greeter.sol
:
yarn hardhat node
yarn hardhat run scripts/Greeter.deploy.ts --network localhost
Результаты:
Deploying Greeter...
0x9A676e781A523b5d0C0e43731313A708CB607508 greeter(proxy) address
0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 getImplementationAddress
0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 getAdminAddress
✨ Done in 2.74s.
Примечание: Если выполнить команду развертывания несколько раз, можно заметить, что adminAddress не меняется.
Теперь нам нужно реализовать функцию increment
в существующем контракте. Вместо замены текущего контракта мы создадим новый контракт и изменим прокси, чтобы он ссылался на новый контракт.
Создание GreeterV2.sol
// contracts/GreeterV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Greeter.sol";
contract GreeterV2 is Greeter {
uint256 public counter;
// Increments the counter value by 1
function increment() public {
counter++;
}
}
Написание модульных тестов для GreeterV2 перед развертыванием.
import { expect } from "chai";
import { BigNumber, Contract } from "ethers";
import { ethers } from "hardhat";
describe("Greeter V2", function () {
let greeterV2: Contract;
beforeEach(async function () {
const GreeterV2 = await ethers.getContractFactory("GreeterV2");
greeterV2 = await GreeterV2.deploy();
await greeterV2.deployed();
});
it("should retrieve value previously stored", async function () {
await greeterV2.setGreeting("Celo to the Moon");
expect(await greeterV2.greet()).to.equal("Celo to the Moon");
});
it("should increment value correctly", async function () {
expect(await greeterV2.counter()).to.equal(BigNumber.from("0"));
await greeterV2.increment();
expect(await greeterV2.counter()).to.equal(BigNumber.from("1"));
});
});
Запуск теста —
yarn hardhat test test/2.GreeterV2.test.ts
Результаты —
Greeter V2
✔ should retrieve value previously stored
✔ should increment value correctly
2 passing (442ms)
✨ Done in 6.43s.
Приведенный выше тест был разработан только для проверки GreeterV2.sol
, а не обновленной версии Greeter.sol
. Давайте напишем и развернем GreeterV2
в proxy patter и проверим, правильно ли работает GreeterV2
.
// test/3.GreeterV2Proxy.test.ts
import { expect } from "chai";
import { Contract } from "ethers";
import { ethers, upgrades } from "hardhat";
describe("Greeter (proxy) V2", function () {
let greeter: Contract;
let greeterV2: Contract;
beforeEach(async function () {
const Greeter = await ethers.getContractFactory("Greeter");
const GreeterV2 = await ethers.getContractFactory("GreeterV2");
greeter = await upgrades.deployProxy(Greeter);
// setting the greet value so that it can be checked after upgrade
await greeter.setGreeting("WAGMI");
greeterV2 = await upgrades.upgradeProxy(greeter.address, GreeterV2);
});
it("should retrieve value previously stored correctly", async function () {
expect(await greeterV2.greet()).to.equal("WAGMI");
await greeter.setGreeting("Celo to the Moon");
expect(await greeterV2.greet()).to.equal("Celo to the Moon");
});
});
Здесь мы устанавливаем значение greeting
на WAGMI в Greeter
(V1) и после обновления проверяем значение greeting
в GreeterV2
.
Запустите тест —
yarn hardhat test test/3.GreeterV2Proxy.test.ts
Результаты —
Greeter (proxy) V2
✔ should retrieve value previously stored correctly
1 passing (521ms)
✨ Done in 6.45s.
Написание скрипта для обновления Greeter
до GreeterV2
.
Обратите внимание, что прокси адрес greeter — 0x9A676e781A523b5d0C0e43731313A708CB607508
, который мы получили при развертывании Greeter.sol
. Нам нужен прокси адрес для развертывания GreeterV2.sol
.
Создайте файл GreeterV2.deploy.ts
и добавьте следующий код.
// scripts/2.upgradeV2.ts
import { ethers, upgrades } from "hardhat";
const proxyAddress = "0x9A676e781A523b5d0C0e43731313A708CB607508";
async function main() {
console.log(proxyAddress, " original Greeter(proxy) address");
const GreeterV2 = await ethers.getContractFactory("GreeterV2");
console.log("upgrade to GreeterV2...");
const greeterV2 = await upgrades.upgradeProxy(proxyAddress, GreeterV2);
console.log(greeterV2.address, " GreeterV2 address(should be the same)");
console.log(
await upgrades.erc1967.getImplementationAddress(greeterV2.address),
" getImplementationAddress"
);
console.log(
await upgrades.erc1967.getAdminAddress(greeterV2.address),
" getAdminAddress"
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Запуск скрипта развертывания для GreeterV2.sol
.
Нам нужно начать с самого начала, т.е. сначала развернуть Greeter.sol, получить адрес прокси и использовать его для развертывания GreeterV2.sol
.
yarn hardhat node
yarn hardhat run scripts/Greeter.deploy.ts --network localhost
Здесь мы получили 0x9A676e781A523b5d0C0e43731313A708CB607508
в качестве адреса прокси. (Обновите адрес прокси в scripts/GreeterV2.deploy.ts
).
yarn hardhat run scripts/GreeterV2.deploy.ts --network localhost
Результаты —
0x9A676e781A523b5d0C0e43731313A708CB607508 original Greeter(proxy) address
upgrade to GreeterV2...
0x9A676e781A523b5d0C0e43731313A708CB607508 GreeterV2 address(should be the same)
0x0B306BF915C4d645ff596e518fAf3F9669b97016 getImplementationAddress
0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82 getAdminAddress
✨ Done in 2.67s.
Переопределение существующих методов
Теперь предположим, что вы хотите добавить пользовательское поле имени в приветствие, чтобы при вызове greet()
имя добавлялось в возвращаемую строку.
Создайте новый файл GreeterV3.sol
в директории contracts
и добавьте следующий код.
// contracts/GreeterV3.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./GreeterV2.sol";
contract GreeterV3 is GreeterV2 {
string public name;
function setName(string memory _name) public {
name = _name;
}
function greet() public view override returns (string memory) {
return string(abi.encodePacked(greeting, " ", name));
}
}
Давайте напишем тестовые примеры для развертывания и тестирования GreeterV3
. Создайте файл 4.GreeterV3Proxy.test.ts
и добавьте следующие тестовые случаи.
// test/.GreeterV3Proxy.test.ts
import { expect } from "chai";
import { BigNumber, Contract } from "ethers";
import { ethers, upgrades } from "hardhat";
describe("Greeter (proxy) V3 with name", function () {
let greeter: Contract;
let greeterV2: Contract;
let greeterV3: Contract;
beforeEach(async function () {
const Greeter = await ethers.getContractFactory("Greeter");
const GreeterV2 = await ethers.getContractFactory("GreeterV2");
const GreeterV3 = await ethers.getContractFactory("GreeterV3");
greeter = await upgrades.deployProxy(Greeter);
// setting the greet value so that it can be checked after upgrade
await greeter.setGreeting("WAGMI");
greeterV2 = await upgrades.upgradeProxy(greeter.address, GreeterV2);
greeterV3 = await upgrades.upgradeProxy(greeter.address, GreeterV3);
});
it("should retrieve value previously stored and increment correctly", async function () {
expect(await greeterV2.greet()).to.equal("WAGMI ");
expect(await greeterV3.counter()).to.equal(BigNumber.from("0"));
await greeterV2.increment();
expect(await greeterV3.counter()).to.equal(BigNumber.from("1"));
});
it("should set name correctly in V3", async function () {
expect(await greeterV3.name()).to.equal("");
const name = "Viral";
await greeterV3.setName(name);
expect(await greeterV3.name()).to.equal(name);
expect(await greeterV3.greet()).to.equal(`WAGMI ${name}`);
});
});
Примечание 1 — Поскольку все данные и состояние хранятся в контракте Proxy, вы можете видеть в тестовых примерах, что мы вызываем метод
increment()
вGreeterV2
и проверяем, увеличилось ли значение или нет вGreeterV3
.Примечание 2 — Поскольку в нашем
GreeterV3.sol
контракте мы добавили пробел и имя кgreeting
Если имя равно null, то вызовgreet
вернетgreeting
с добавленным к нему пробелом.
Запустите тесты —
yarn hardhat test test/4.GreeterV3Proxy.test.ts
Результаты —
Greeter (proxy) V3 with name
✔ should retrieve value previously stored and increment correctly
✔ should set name correctly in V3
2 passing (675ms)
✨ Done in 2.38s.
Сценарий развертывания для GreeterV3.sol
Создайте файл с именем GreeterV3.deploy.ts
в scripts
и вставьте в него следующий код —
// scripts/3.upgradeV3.ts
import { ethers, upgrades } from "hardhat";
const proxyAddress = "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1";
async function main() {
console.log(proxyAddress, " original Greeter(proxy) address");
const GreeterV3 = await ethers.getContractFactory("GreeterV3");
console.log("upgrade to GreeterV3...");
const greeterV3 = await upgrades.upgradeProxy(proxyAddress, GreeterV3);
console.log(greeterV3.address, " GreeterV3 address(should be the same)");
console.log(
await upgrades.erc1967.getImplementationAddress(greeterV3.address),
" getImplementationAddress"
);
console.log(
await upgrades.erc1967.getAdminAddress(greeterV3.address),
" getAdminAddress"
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Разверните GreeterV3
.
yarn hardhat run scripts/GreeterV3.deploy.ts --network localhost
Примечание — Если вы получите сообщение «Ошибка: Proxy admin is not the one registered in the network manifest» при попытке развернуть
GreeterV3
, вам нужно снова запуститьGreeter.deploy.ts
иGreeterV2.deploy.ts
и скопировать новый адрес прокси, который будет использоваться для развертывания обновленных контрактов.
Результаты —
0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1 original Greeter(proxy) address
upgrade to GreeterV3...
0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1 GreeterV3 address(should be the same)
0x3Aa5ebB10DC797CAC828524e59A333d0A371443c getImplementationAddress
0x59b670e9fA9D0A427751Af201D676719a970857b getAdminAddress
✨ Done in 2.63s.
Развертывание обновленного контракта вручную
Давайте напишем новый контракт — GreeterV4
, где нам нужно —
- Изменить переменную состояния
name
с public на private. - Добавить метод
getName
для получения значенияname
.
Создайте файл GreeterV4.sol
в директории contracts
и добавьте следующий код.
// contracts/GreeterV4.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./GreeterV2.sol";
contract GreeterV4 is GreeterV2 {
string private name;
event NameChanged(string name);
function setName(string memory _name) public {
name = _name;
}
function getName() public view returns (string memory) {
return name;
}
}
Здесь нам нужно наследоваться от GreeterV2
вместо GreeterV3
, потому что GreeterV3
уже имеет переменную состояния name и мы не можем изменить видимость, но что мы можем сделать, так это наследоваться от GreeterV3
, который не имеет переменной name
и установить видимость так, как мы хотим.
Давайте напишем тестовые примеры для GreeterV4.sol
.
// test/5.GreeterV4Proxy.test.ts
/* eslint-disable no-unused-vars */
import { expect } from "chai";
import { Contract } from "ethers";
import { ethers, upgrades } from "hardhat";
describe("Greeter (proxy) V4 with getName", function () {
let greeter: Contract;
let greeterV2: Contract;
let greeterV3: Contract;
let greeterV4: Contract;
beforeEach(async function () {
const Greeter = await ethers.getContractFactory("Greeter");
const GreeterV2 = await ethers.getContractFactory("GreeterV2");
const GreeterV3 = await ethers.getContractFactory("GreeterV3");
const GreeterV4 = await ethers.getContractFactory("GreeterV4");
greeter = await upgrades.deployProxy(Greeter);
greeterV2 = await upgrades.upgradeProxy(greeter.address, GreeterV2);
greeterV3 = await upgrades.upgradeProxy(greeter.address, GreeterV3);
greeterV4 = await upgrades.upgradeProxy(greeter.address, GreeterV4);
});
it("should setName and getName correctly in V4", async function () {
expect(await greeterV4.getName()).to.equal("");
const greetername = "Celo";
await greeterV4.setName(greetername);
expect(await greeterV4.getName()).to.equal(greetername);
});
});
Запустите тест —
yarn hardhat test test/5.GreeterV4Proxy.test.ts
Результаты —
Greeter (proxy) V4 with getName
✔ should setName and getName correctly in V4
1 passing (608ms)
✨ Done in 6.18s.
Подготовка к обновлению, но не настоящее обновление
Когда мы вызываем upgrades.upgradeProxy()
, происходит несколько вещей —
- Сначала разворачивается ваш контракт,
- ProxyAdmin вызывает метод
upgrade()
и связывает прокси с адресом нового внедренного контракта.
Чтобы выполнить эти шаги вручную, мы можем вызвать upgrades.prepareUpgrade()
, который только развернет ваш контракт, но не свяжет его с прокси YET. Разработчики могут связать его вручную. Это полезно, когда вы хотите протестировать в продакшене, прежде чем все пользователи захотят использовать новый контракт.
Создайте файл GreeterV4Prepare.deploy.ts
в директории scripts
и добавьте следующий код.
import { ethers, upgrades } from "hardhat";
const proxyAddress = "0xc5a5C42992dECbae36851359345FE25997F5C42d";
async function main() {
console.log(proxyAddress, " original Greeter(proxy) address");
const GreeterV4 = await ethers.getContractFactory("GreeterV4");
console.log("Preparing upgrade to GreeterV4...");
const greeterV4Address = await upgrades.prepareUpgrade(
proxyAddress,
GreeterV4
);
console.log(greeterV4Address, " GreeterV4 implementation contract address");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Примечание — Вам может понадобиться запустить все сценарии развертывания снова, если вы столкнетесь с какими-либо проблемами при выполнении этого сценария.
Запустите сценарий —
yarn hardhat run scripts/GreeterV4Prepare.deploy.ts --network localhost
Результаты —
0xc5a5C42992dECbae36851359345FE25997F5C42d original Greeter(proxy) address
Preparing upgrade to GreeterV4...
0x9E545E3C0baAB3E08CdfD552C960A1050f373042 GreeterV4 implementation contract address
✨ Done in 2.56s.
Развертывание всех контрактов в сети Alfajores компании Celo
Сначала нам нужно добавить детали RPC Alfajores в hardhat.config.ts
. Обратитесь к этому сайту для получения последней информации о RPC — https://docs.celo.org/getting-started/wallets/using-metamask-with-celo/manual-setup.
alfajores: {
url: "https://alfajores-forno.celo-testnet.org",
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
Запустите скрипт и разверните Greeter.sol
.
Для справки, вот три развернутых контракта —
Обновление Greeter
до GreeterV2
.
Выполните следующую команду для развертывания GreeterV2
. Перед запуском обновите proxyAddress
в GreeterV2.deploy.ts
на адрес контракта развернутого прокси.
yarn hardhat run scripts/GreeterV2.deploy.ts --network alfajores
Поскольку мы вызвали upgrades.upgradeProxy
в сценарии развертывания, он сделал две вещи, развернул новый контракт реализации и вызвал proxyAdmin.upgrade()
метод для связи прокси и нового контракта реализации.
Обновление GreeterV2
до GreeterV3
.
Выполните следующую команду —
yarn hardhat run scripts/GreeterV3.deploy.ts --network alfajores
Обновление GreeterV3
до GreeterV4
.
В GreeterV4Prepare.deploy.ts
мы вызвали только prepareUpgrade
, который только развертывает контракт реализации. Мы будем использовать консоль hardhat, чтобы вручную вызвать update()
метод для связывания прокси с контрактом реализации. Выполните следующую команду, чтобы развернуть контракт реализации.
yarn hardhat run scripts/GreeterV4Prepare.deploy.ts --network alfajores
Результаты —
0xd15100A570158b7EEbE196405D8a456d56807F2d original Greeter(proxy) address
Preparing upgrade to GreeterV4...
0x8843F73D7c761D29649d4AC15Ee9501de12981c3 GreeterV4 implementation contract address
✨ Done in 5.81s.
Мы будем использовать консоль hardhat для связи прокси с контрактом реализации. Выполните следующую команду, чтобы запустить консоль.
yarn hardhat console --network alfajores
Для связывания нам понадобятся две вещи —
- Адрес ProxyAdmin
Выполните следующие команды в консоли —
> const GreeterV4 = await ethers.getContractFactory("GreeterV4");
> await upgrades.upgradeProxy("0xd15100A570158b7EEbE196405D8a456d56807F2d", GreeterV4);
Это укажет прокси контракт на последнюю версию GreeterV4
.
Заключение
Поздравляю 💪 с тем, что вы дошли до конца. Я знаю, что эта статья получилась длинноватой, но теперь вы знаете, как обновляемые смарт-контракты работают под капотом. Вы создали и развернули прокси-контракты в локальном тестнете и тестнете Alfajores. Если у вас есть сомнения или вы просто хотите сказать «Привет 👋🏻», вы можете связаться со мной в Twitter или Discord[0xViral (Celo)#6692].
- Celo Discord
- Celo Twitter