There are several good articles about upgradeable smart contracts or proxy pattern. But I can't find a step-by-step how-to guide to build, deploy a proxy contract and interact with it. So I wrote one and here it is.
In this tutorial, I list 7 details tasks which you can follow to deploy proxy contract to local testnet as well as public testnet Ropsten. Each task has several sub-tasks.
We will use OpenZeppelin proxy contracts and OpenZeppelin Upgrades plugin for Hardhat (or Truffle) .
Special thanks for two related guides in OpenZeppelin ecosystem: OpenZeppelin Upgrades: Step by Step Tutorial for Hardhat and Gnosis Safe by abcoathup, OpenZeppelin Defender guide Upgrading a contract via a multi-sig and Defender.
How upgradeable smart contract works?
This illustration explains how upgradeable smart contracts work. To be specific, this is transparent proxy pattern. Another one is UUPS proxy pattern (Universal Upgradeable Proxy Standard).
Upgradeable smart contracts are composed of 3 contracts:
Proxy contract. The smart contract which user interacts with. It will keep data/state which means data is stored in the context of this proxy contract account. This is an EIP1967 standard proxy contract.
Implementation contract. The smart contract provides functionality and logic. Please note that the data is also defined in this contract. This is the smart contract you are building.
ProxyAdmin contract. The contract links Proxy and Implementation.
ProxyAmdin explained in OpenZeppelin docs:
What is a proxy admin?
A ProxyAdmin is a contract that acts as the owner of all your proxies. Only one per network gets deployed. When you start your project, the ProxyAdmin is owned by the deployer address, but you can transfer ownership of it by calling transferOwnership.
If you transfer ProxyAmin ownership to a multi-sig account, the authority to upgrade the Proxy contract (link proxy to new implementation) is transferred to it.
How to deploy proxy? How to upgrade proxy?
When we first deploy upgradeable contract using OpenZeppelin Upgrades plugin for Hardhat, we deploy three contracts:
- deploy "Implementation contract"
- deploy "ProxyAdmin contract"
- deploy "Proxy contract"
In the ProxyAdmin contract, Implementation and Proxy is linked.
When a user calls the proxy contract, the call is delegated to the implementation contract (delegate call).
When upgrading the contract, what we do is:
- deploy a new "Implementation contract"
- upgrade in "ProxyAdmin contract" by redirecting all calls to proxy to the new Implementation contract.
OpenZeppelin Upgrades plugins for Hardhat/Truffle can help us getting these jobs done.
If you want to know about how to modify a contract to be upgradeable, you can refer to OpenZeppelin docs: link.
Let's begin to write and deploy an upgradeable smart contract. You can find the repo at Github: https://github.com/fjun99/proxy-contract-example
Task 1: Write upgradable smart contract
Task 1.1: init hardhat project
We will use Hardhat, Hardhat Network local testnet and OpenZeppelin Upgrades plugin.
STEP 1: install hardhat and init a project
mkdir solproxy && cd solproxy
yarn init -y
yarn add harthat
yarn hardhat
// choose option: sample typescript
STEP 2: add plugin @openzeppelin/hardhat-upgrades
yarn add @openzeppelin/hardhat-upgrades
Edit hardhat.config.ts
to use Upgrades plugins.
// hardhat.config.ts
import '@openzeppelin/hardhat-upgrades';
We will use three functions of hardhat upgrade plugin(API reference link):
deployProxy()
upgradeProxy()
prepareUpgrade()
1.2: Write an upgradable smart contract
We use the Box.sol
contract from OpenZeppelin learn guide. We will build several versions of this contract:
- Box.sol
- BoxV2.sol
- BoxV3.sol
- BoxV4.sol
The big difference between a normal contract and an upgradeable contract is that an upgradeable contract does not have a constructor()
, docs link.
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Box {
uint256 private value;
// Emitted when the stored value changes
event ValueChanged(uint256 newValue);
// Stores a new value in the contract
function store(uint256 newValue) public {
value = newValue;
emit ValueChanged(newValue);
}
// Reads the last stored value
function retrieve() public view returns (uint256) {
return value;
}
}
User can store()
a value to this contract and retrieve()
it later.
Task 1.3: unit test script for Box.sol
Let's write unit test scripts for Box.sol
. The following hardhat unit test script is adapted from (
OpenZeppelin Upgrades: Step by Step Tutorial for Hardhat). We made some changes in it.
Edit test/1.Box.test.ts
:
// test/1.Box.test.ts
import { expect } from "chai";
import { ethers } from "hardhat"
import { Contract, BigNumber } from "ethers"
describe("Box", function () {
let box:Contract;
beforeEach(async function () {
const Box = await ethers.getContractFactory("Box")
box = await Box.deploy()
await box.deployed()
})
it("should retrieve value previously stored", async function () {
await box.store(42)
expect(await box.retrieve()).to.equal(BigNumber.from('42'))
await box.store(100)
expect(await box.retrieve()).to.equal(BigNumber.from('100'))
})
})
// NOTE: should also add test for event: event ValueChanged(uint256 newValue)
Run test:
yarn hardhat test test/1.Box.test.ts
Results:
Box
✓ should retrieve value previously stored
1 passing (505ms)
✨ Done in 3.34s.
Task 2: Deploy upgradeable smart contract
Task 2.1: write deploy script with "OpenZeppelin Upgrades plugin for Hardhat"
When we write a script to deploy a smart contract, we write:
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, Hardhat!");
To deploy an upgradeable contract, will call deployProxy()
instead. Docs can be found at link.
const Box = await ethers.getContractFactory("Box")
const box = await upgrades.deployProxy(Box,[42], { initializer: 'store' })
In the second line, we use OpenZeppelin Upgrades plugin to deploy Box
with an initial value 42
by calling store()
as initializer.
Edit scripts/1.deploy_box.ts
// scripts/1.deploy_box.ts
import { ethers } from "hardhat"
import { upgrades } from "hardhat"
async function main() {
const Box = await ethers.getContractFactory("Box")
console.log("Deploying Box...")
const box = await upgrades.deployProxy(Box,[42], { initializer: 'store' })
console.log(box.address," box(proxy) address")
console.log(await upgrades.erc1967.getImplementationAddress(box.address)," getImplementationAddress")
console.log(await upgrades.erc1967.getAdminAddress(box.address)," getAdminAddress")
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
Explanation:
- We deploy an upgradeable contract
Box.sol
usingupgrades.deployProxy()
. - Three contracts will be deployed: Implementation, ProxyAdmin, Proxy. We log their addresses for inspection.
Task 2.2: deploy the contract to local testnet
Let's run the deploy script in local testnet.
STEP 1: run a stand-alone hardhat testnet in another terminal:
yarn hardhat node
STEP 2: run the deploy script
yarn hardhat run scripts/1.deploy_box.ts --network localhost
Results:
Deploying Box...
0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 box(proxy) address
0x5FbDB2315678afecb367f032d93F642f64180aa3 getImplementationAddress
0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 getAdminAddress
✨ Done in 3.83s.
Users can interact with the box contract through box(proxy) address: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
.
Note: if you run this deployment for several times, you can find that ProxyAdmin is always the same: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Task 2.3: test whether Box.sol
using proxy pattern works correctly
Users interact with the implementation contract through the box(proxy) contract.
To make sure that they work correctly, let's add unit test for this scenario. In the unit test, we deploy our contract using upgrades.deployProxy()
and interact through the box(proxy) contract.
Edit test/2.BoxProxy.test.ts
// test/2.BoxProxy.test.ts
import { expect } from "chai"
import { ethers, upgrades } from "hardhat"
import { Contract, BigNumber } from "ethers"
describe("Box (proxy)", function () {
let box:Contract
beforeEach(async function () {
const Box = await ethers.getContractFactory("Box")
//initialize with 42
box = await upgrades.deployProxy(Box, [42], {initializer: 'store'})
})
it("should retrieve value previously stored", async function () {
// console.log(box.address," box(proxy)")
// console.log(await upgrades.erc1967.getImplementationAddress(box.address)," getImplementationAddress")
// console.log(await upgrades.erc1967.getAdminAddress(box.address), " getAdminAddress")
expect(await box.retrieve()).to.equal(BigNumber.from('42'))
await box.store(100)
expect(await box.retrieve()).to.equal(BigNumber.from('100'))
})
})
Run test:
yarn hardhat test test/2.BoxProxy.test.ts
Results:
Box (proxy)
✓ should retrieve value previously stored
1 passing (579ms)
✨ Done in 3.12s.`
Our box.sol is working correctly now.
Later, we find that we need an increment()
function. Instead of re-deploying this contract, migrate data to the new contract and ask all users to access the new contract address. We can upgrade the contract easily thanks to the proxy pattern.
Task 3: Upgrade smart contract to BoxV2
Task 3.1: write new implementation
We write a new version of Box BoxV2.sol
by inherit Box.sol
.
Edit contracts/BoxV2.sol
// contracts/BoxV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Box.sol";
contract BoxV2 is Box{
// Increments the stored value by 1
function increment() public {
store(retrieve()+1);
}
}
Task 3.2: test script for normal deployment
We write a unit test script to test BoxV2 when deployed locally.
Edit test/3.BoxV2.test.ts
// test/3.BoxV2.test.ts
import { expect } from "chai"
import { ethers } from "hardhat"
import { Contract, BigNumber } from "ethers"
describe("Box V2", function () {
let boxV2:Contract
beforeEach(async function () {
const BoxV2 = await ethers.getContractFactory("BoxV2")
boxV2 = await BoxV2.deploy()
await boxV2.deployed()
});
it("should retrieve value previously stored", async function () {
await boxV2.store(42)
expect(await boxV2.retrieve()).to.equal(BigNumber.from('42'))
await boxV2.store(100)
expect(await boxV2.retrieve()).to.equal(BigNumber.from('100'))
});
it('should increment value correctly', async function () {
await boxV2.store(42)
await boxV2.increment()
expect(await boxV2.retrieve()).to.equal(BigNumber.from('43'))
})
})
Run test:
yarn hardhat test test/3.BoxV2.test.ts
results:
Box V2
✓ should retrieve value previously stored
✓ should increment value correctly
2 passing (579ms)
✨ Done in 3.38s.
Task 3.3: test script for upgradeable deployment
We write a unit test script for BoxV2 deployed in proxy pattern:
- first, we deploy the Box.sol
- then we upgrade it to BoxV2.sol
- test whether BoxV2 works correctly.
Edit test/4.BoxProxyV2.test.ts
:
// test/4.BoxProxyV2.test.ts
import { expect } from "chai"
import { ethers, upgrades } from "hardhat"
import { Contract, BigNumber } from "ethers"
describe("Box (proxy) V2", function () {
let box:Contract
let boxV2:Contract
beforeEach(async function () {
const Box = await ethers.getContractFactory("Box")
const BoxV2 = await ethers.getContractFactory("BoxV2")
//initilize with 42
box = await upgrades.deployProxy(Box, [42], {initializer: 'store'})
// console.log(box.address," box/proxy")
// console.log(await upgrades.erc1967.getImplementationAddress(box.address)," getImplementationAddress")
// console.log(await upgrades.erc1967.getAdminAddress(box.address), " getAdminAddress")
boxV2 = await upgrades.upgradeProxy(box.address, BoxV2)
// console.log(boxV2.address," box/proxy after upgrade")
// console.log(await upgrades.erc1967.getImplementationAddress(boxV2.address)," getImplementationAddress after upgrade")
// console.log(await upgrades.erc1967.getAdminAddress(boxV2.address)," getAdminAddress after upgrade")
})
it("should retrieve value previously stored and increment correctly", async function () {
expect(await boxV2.retrieve()).to.equal(BigNumber.from('42'))
await boxV2.increment()
//result = 42 + 1 = 43
expect(await boxV2.retrieve()).to.equal(BigNumber.from('43'))
await boxV2.store(100)
expect(await boxV2.retrieve()).to.equal(BigNumber.from('100'))
})
})
Run test:
yarn hardhat test test/4.BoxProxyV2.test.ts
Results:
Box (proxy) V2
✓ should retrieve value previously stored and increment correctly
1 passing (617ms)
✨ Done in 3.44s.
Task 3.4: write upgrade script
In sub-task 2.2, we deploy Box(proxy) to 0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0
.
In this sub-task, we will upgrade it to BoxV2 (deploy a new contract , and link proxy to a new implementation contract in ProxyAdmin):
Edit
// scripts/2.upgradeV2.ts
import { ethers } from "hardhat";
import { upgrades } from "hardhat";
const proxyAddress = '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0'
async function main() {
console.log(proxyAddress," original Box(proxy) address")
const BoxV2 = await ethers.getContractFactory("BoxV2")
console.log("upgrade to BoxV2...")
const boxV2 = await upgrades.upgradeProxy(proxyAddress, BoxV2)
console.log(boxV2.address," BoxV2 address(should be the same)")
console.log(await upgrades.erc1967.getImplementationAddress(boxV2.address)," getImplementationAddress")
console.log(await upgrades.erc1967.getAdminAddress(boxV2.address), " getAdminAddress")
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
Task 3.5: run the upgrade script
We will start over from the beginning.
STEP 1: run a new local testnet in another terminal:
yarn hardhat node
STEP 2: deploy Box V1
yarn hardhat run scripts/1.deploy_box.ts --network localhost
The box(proxy) will be deployed to: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
.
STEP 3: upgrade to Box V2
yarn hardhat run scripts/2.upgradeV2.ts --network localhost
results:
0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0 original Box(proxy) address
upgrade to BoxV2...
0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0 BoxV2 address(should be the same)
0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 getImplementationAddress
0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 getAdminAddress
✨ Done in 3.64s.
Task 4: Play with contract in hardhat console
Let's play with Box(proxy) contract in hardhat console.
Task 4.1: play with the proxy contract
Run hardhat console connecting to local testnet.
yarn hardhat console --network localhost
In hardhat console:
address = '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0'
boxv2 = await ethers.getContractAt("BoxV2", address)
await boxv2.retrieve()
//BigNumber { value: "42" }
await boxv2.increment()
// tx response
// {
// hash: '0x3e8c9dd8842d3315cadad2a80b592ac369e644edc5cec16f7a22c76d49e4b921',
// blockNumber: 6,
await boxv2.retrieve()
//BigNumber { value: "43" }
await boxv2.store(100)
// tx response ...
await boxv2.retrieve()
//BigNumber { value: "100" }
Task 4.2: try to interact with the implementation contact
Let's try to interact with the implementation contact to see what's happening.
addressimp = '0x5fc8d32690cc91d4c39d9d3abcbd16989f875707'
boximp = await ethers.getContractAt("BoxV2", addressimp)
await boximp.retrieve()
//BigNumber { value: "0" }
await boximp.increment()
// tx response ...
await boximp.retrieve()
BigNumber { value: "1" }
We will find that the original value in the implementation is 0
. This is because the data is stored in the context of Proxy contract (0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0
) under the hood, not in the context of Implementation contract(0x5fc8d32690cc91d4c39d9d3abcbd16989f875707
).
Task 5: Build BoxV3 to add new state variable
We can add new state variables as long as you don't change the the layout of the state variables.
To put it simply, you can add a new one. We will add a state variable string public name
.
Task 5.1: write BoxV3.sol
We write BoxV3.sol
by inheriting BoxV2
:
// contracts/BoxV3.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./BoxV2.sol";
contract BoxV3 is BoxV2{
string public name;
event NameChanged(string name);
function setName(string memory _name) public {
name = _name;
emit NameChanged(name);
}
}
Task 5.2: write test for proxy deployment
We skip local unit test here. We will test the proxy deployment only.
// test/5.BoxProxyV3.test.ts
import { expect } from "chai"
import { ethers, upgrades } from "hardhat"
import { Contract, BigNumber } from "ethers"
describe("Box (proxy) V3 with name", function () {
let box:Contract
let boxV2:Contract
let boxV3:Contract
beforeEach(async function () {
const Box = await ethers.getContractFactory("Box")
const BoxV2 = await ethers.getContractFactory("BoxV2")
const BoxV3 = await ethers.getContractFactory("BoxV3")
//initialize with 42
box = await upgrades.deployProxy(Box, [42], {initializer: 'store'})
boxV2 = await upgrades.upgradeProxy(box.address, BoxV2)
boxV3 = await upgrades.upgradeProxy(box.address, BoxV3)
})
it("should retrieve value previously stored and increment correctly", async function () {
expect(await boxV2.retrieve()).to.equal(BigNumber.from('42'))
await boxV3.increment()
expect(await boxV2.retrieve()).to.equal(BigNumber.from('43'))
await boxV2.store(100)
expect(await boxV2.retrieve()).to.equal(BigNumber.from('100'))
})
it("should set name correctly in V3", async function () {
expect(await boxV3.name()).to.equal("")
const boxname="my Box V3"
await boxV3.setName(boxname)
expect(await boxV3.name()).to.equal(boxname)
})
})
// NOTE: should also add test for event: event NameChanged(string name)
Run Test:
yarn hardhat test test/5.BoxProxyV3.test.ts
Results:
Box (proxy) V3 with name
✓ should retrieve value previously stored and increment correctly
✓ should set name correctly in V3
2 passing (748ms)
✨ Done in 3.12s.
Task 5.3: write upgrade script
Edit scripts/3.upgradeV3.ts
:
// scripts/3.upgradeV3.ts
import { ethers } from "hardhat";
import { upgrades } from "hardhat";
const proxyAddress = '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0'
// const proxyAddress = '0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268'
async function main() {
console.log(proxyAddress," original Box(proxy) address")
const BoxV3 = await ethers.getContractFactory("BoxV3")
console.log("upgrade to BoxV3...")
const boxV3 = await upgrades.upgradeProxy(proxyAddress, BoxV3)
console.log(boxV3.address," BoxV3 address(should be the same)")
console.log(await upgrades.erc1967.getImplementationAddress(boxV3.address)," getImplementationAddress")
console.log(await upgrades.erc1967.getAdminAddress(boxV3.address), " getAdminAddress")
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
Task 5.4: deploy and interact with BoxV3
STEP 1: upgrade to BoxV3
yarn hardhat run scripts/3.upgradeV3.ts --network localhost
results:
0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0 original Box(proxy) address
upgrade to BoxV3...
0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0 BoxV3 address(should be the same)
0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 getImplementationAddress
0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 getAdminAddress
✨ Done in 3.52s.
STEP 2: play with V3 in hardhat console
yarn hardhat console --network localhost
address = '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0'
boxv3 = await ethers.getContractAt("BoxV3", address)
await boxv3.retrieve()
//BigNumber { value: "42" }
await boxv3.setName("mybox")
// tx response
await boxv3.name()
//'mybox'
STEP 3: try to play with the implementation contract directly again
addressimp = '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853'
boximp = await ethers.getContractAt("BoxV3", addressimp)
await boximp.retrieve()
//BigNumber { value: "0" }
await boximp.name()
//''
You can find explanations about data change in openzeppelin docsopenzeppelin docs:
Due to technical limitations, when you upgrade a contract to a new version you cannot change the storage layout of that contract.
This means that, if you have already declared a state variable in your contract, you cannot remove it, change its type, or declare another variable before it. In our Box example, it means that we can only add new state variables after value.
Task 6: Write BoxV4 with prepareUpgrade script
We write BoxV4 and prepare upgrade script for Task 7 in which we will deploy and upgrade these versions of Box to public testnet Ropsten manually call ProxyAdmin.upgrade()
.
Let's write BoxV4.sol
, unit test and upgrade script.
Task 6.1: write BoxV4.sol
We write a BoxV4 which change how we interact with name
state variable:
- We change the state variable
name
frompublic
toprivate
. - When user
getName()
, a prefix is added.
// contracts/BoxV4.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./BoxV2.sol";
contract BoxV4 is BoxV2{
string private name;
event NameChanged(string name);
function setName(string memory _name) public {
name = _name;
emit NameChanged(name);
}
function getName() public view returns(string memory){
return string(abi.encodePacked("Name: ",name));
}
}
Task 6.2 write test for proxy deployment
Edit test/6.BoxProxyV4.test.ts
:
// test/6.BoxProxyV4.test.ts
import { expect } from "chai"
import { ethers, upgrades } from "hardhat"
import { Contract, BigNumber } from "ethers"
describe("Box (proxy) V4 with getName", function () {
let box:Contract
let boxV2:Contract
let boxV3:Contract
let boxV4:Contract
beforeEach(async function () {
const Box = await ethers.getContractFactory("Box")
const BoxV2 = await ethers.getContractFactory("BoxV2")
const BoxV3 = await ethers.getContractFactory("BoxV3")
const BoxV4 = await ethers.getContractFactory("BoxV4")
//initialize with 42
box = await upgrades.deployProxy(Box, [42], {initializer: 'store'})
boxV2 = await upgrades.upgradeProxy(box.address, BoxV2)
boxV3 = await upgrades.upgradeProxy(box.address, BoxV3)
boxV4 = await upgrades.upgradeProxy(box.address, BoxV4)
})
it("should retrieve value previously stored and increment correctly", async function () {
expect(await boxV4.retrieve()).to.equal(BigNumber.from('42'))
await boxV4.increment()
expect(await boxV4.retrieve()).to.equal(BigNumber.from('43'))
await boxV2.store(100)
expect(await boxV2.retrieve()).to.equal(BigNumber.from('100'))
})
it("should setName and getName correctly in V4", async function () {
//name() removed, getName() now
// expect(boxV4).to.not.have.own.property("name")
expect(boxV4.name).to.be.undefined
expect(await boxV4.getName()).to.equal("Name: ")
const boxname="my Box V4"
await boxV4.setName(boxname)
expect(await boxV4.getName()).to.equal("Name: "+boxname)
})
})
Run Test:
yarn hardhat test test/6.BoxProxyV4.test.ts
Results:
Box (proxy) V4 with getName
✓ should retrieve value previously stored and increment correctly
✓ should setName and getName correctly in V4
2 passing (771ms)
✨ Done in 2.46s.
Task 6.3: write script to prepare upgrade
We will write this script using upgrades.prepareUpgrade
.
When calling upgrades.upgradeProxy()
, two jobs are done:
- an implementation contract is deployed
- ProxyAdmin
upgrade()
is called to link Proxy and implementation contract.
When calling upgrades.prepareUpgrade()
, only the first job is done, and the second is left for developers to do manually.
Edit scripts/4.prepareV4.ts
// scripts/4.prepareV4.ts
import { ethers } from "hardhat";
import { upgrades } from "hardhat";
const proxyAddress = '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0'
// const proxyAddress = '0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268'
async function main() {
console.log(proxyAddress," original Box(proxy) address")
const BoxV4 = await ethers.getContractFactory("BoxV4")
console.log("Preparing upgrade to BoxV4...");
const boxV4Address = await upgrades.prepareUpgrade(proxyAddress, BoxV4);
console.log(boxV4Address, " BoxV4 implementation contract address")
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
Run deployment:
yarn hardhat run scripts/4.prepareV4.ts --network localhost
Results:
0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0 original Box(proxy) address
Preparing upgrade to BoxV4...
0x610178dA211FEF7D417bC0e6FeD39F05609AD788 BoxV4 implementation contract address
✨ Done in 3.90s.
You can interact with proxy contract (0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0
) in hardhat console by running yarn hardhat console --network localhost
address = '0x9fe46736679d2d9a65f0992f2272de9f3c7fa6e0'
boxv4 = await ethers.getContractAt("BoxV4", address)
await boxv4.getName()
//We will get error:
//ProviderError: Error: Transaction reverted: function selector was not recognized
Task 7: Deploy Box to public testnet Ropsten
Task 7.1: preparations to deploy to Ropsten
We can deploy smart contract to Ropsten directly using Hardhat.
STEP 1: Edit Alchemy/Infura endpoint in .env
ROPSTEN_URL=https://eth-ropsten.alchemyapi.io/v2/{YOURS}
PRIVATE_KEY={YOURS HERE}
STEP 2: Make sure you have Ropsten set in hardhat.config.ts
:
networks: {
ropsten: {
url: process.env.ROPSTEN_URL || "",
accounts:
process.env.PRIVATE_KEY !== undefined ? [process.env.PRIVATE_KEY] : [],
},
},
Task 7.2: deploy Box V1
Run deploy script:
yarn hardhat run scripts/1.deploy_box.ts --network ropsten
Three contracts are deployed:
-
0x7fcb5f0898ee1394c6cb44e3a62b9e9fc19d0e1c
, implementation contract -
0x9ce0fdc88df321c804ed0ad9cefe87d97a30479e
, ProxyAmin contract -
0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268
, proxy contract
We can interact with the proxy contract from hardhat console by running yarn hardhat console --network ropsten
In console:
address = '0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268'
box = await ethers.getContractAt("Box", address)
await box.retrieve()
//BigNumber { value: "43" }
Task 7.3: upgrade to Box V2
Run our upgrade script:
yarn hardhat run scripts/2.upgradeV2.ts --network ropsten
The script does two jobs:
- deploy new implementation contract
- call ProxyAmdin.upgrade()
Let's interact with the Box(proxy) in console:
address = '0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268'
box = await ethers.getContractAt("BoxV2", address)
await box.increment()
//wait for tx to be mined in a new block...
await box.retrieve()
//BigNumber { value: "44" }
Task 7.4: upgrade to Box V3
Run our upgrade script:
yarn hardhat run scripts/3.upgradeV3.ts --network ropsten
Let's interact with the Box(proxy) with BoxV3 in console:
address = '0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268'
box = await ethers.getContractAt("BoxV3", address)
box = await ethers.getContractAt("BoxV3", address)
await box.setName("mybox")
//wait for tx to be mined in a new block...
Task 7.5: prepare upgrade to Box V4
Run the prepare upgrade script:
yarn hardhat run scripts/4.prepareV4.ts --network ropsten
This script will deploy a BoxV4 contract at 0xA0726E6e045f84dEe8D7cA4CdD427A68dd336458
.
We will upgrade in block explorer https://ropsten.etherscan.io
manually.
ProxyAdmin is already verified on Etherscan block explorer. In ProxyAmdin contract write page, connect to wallet and run upgrade()
like in the screenshot.
address = '0x1CD0c84b7C7C1350d203677Bb22037A92Cc7e268'
box = await ethers.getContractAt("BoxV4", address)
box = await ethers.getContractAt("BoxV4", address)
await box.getName()
//'Name: mynewbox'
Task 7.6: read ProxyAdmin contract
You can also read ProxyAdmin contract to get more information about the Box(proxy) contract:
- getProxyAdmin
- getProxyImplementation
- owner
If you would like to verify Box, BoxV2, BoxV3, BoxV4 contract in Etherscan, you can use hardhat plugin hardhat-etherscan
.
Install plugin:
yarn add @nomiclabs/hardhat-etherscan
Verify BoxV4:
yarn hardhat verify 0xA0726E6e045f84dEe8D7cA4CdD427A68dd336458 --network ropsten
You have built and deploy proxy contract in local testnet and public testnet Ropsten in 7 tasks. You can continue to do more:
Use OpenZeppelin Defender to manage upgrading.
Transfer ProxyAdmin owner to a multi-sig address such as Gnosis Safe multi-sig wallet.
References you may be interested in:
- https://docs.openzeppelin.com/learn/upgrading-smart-contracts#upgrading
- https://docs.openzeppelin.com/defender/guide-upgrades
- https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
- https://www.preethikasireddy.com/post/the-hardest-concept-for-developers-to-grasp-about-web-3-0
- https://forum.openzeppelin.com/t/openzeppelin-upgrades-step-by-step-tutorial-for-hardhat/3580
- https://blog.openzeppelin.com/proxy-patterns/
- https://blog.trailofbits.com/2018/09/05/contract-upgrade-anti-patterns/
- https://blog.openzeppelin.com/the-state-of-smart-contract-upgrades/
Tutorial List:
1. A Concise Hardhat Tutorial(3 parts)
https://dev.to/yakult/a-concise-hardhat-tutorial-part-1-7eo
2. Understanding Blockchain with Ethers.js
(5 parts)
https://dev.to/yakult/01-understanding-blockchain-with-ethersjs-4-tasks-of-basics-and-transfer-5d17
3. Tutorial : build your first DAPP with Remix and Etherscan (7 Tasks)
https://dev.to/yakult/tutorial-build-your-first-dapp-with-remix-and-etherscan-52kf
4. Tutorial: build DApp with Hardhat, React and Ethers.js (6 Tasks)
https://dev.to/yakult/a-tutorial-build-dapp-with-hardhat-react-and-ethersjs-1gmi
5. Tutorial: build DAPP with Web3-React and SWR
https://dev.to/yakult/tutorial-build-dapp-with-web3-react-and-swr-1fb0
6. Tutorial: write upgradeable smart contract (proxy) using OpenZeppelin(7 Tasks)
7. Tutorial: Build a NFT marketplace DApp like Opensea(5 Tasks)
https://dev.to/yakult/tutorial-build-a-nft-marketplace-dapp-like-opensea-3ng9
If you find this tutorial helpful, follow me at Twitter @fjun99
Top comments (6)
Awesome! Greate job
Thank you so much, this is the first time I see a nitty-gritty article like that.
Great article!
I've a question. At the time of v4, we discarded v3 and instead took v2 as the base contract - the reason was that we wanted to change the implementation of setName function and the access modifier of name variable right?
So, what if we add function "A" in v3.
And we reached till v20, in that, we added functions - B to N
But now, in v20 we want to change the implementation of function "A".
Then in that case, we need to use v2 as the base of v21 contract? And what will happen to all the contracts from B to N?
I think:
_v2 must be the base all the time ,
_from B to N it will live on chain but the user only interact with v21 by the base address (v2).
_if you want to interact with B to N it will be done by itself address .
All the above worked fine until Task 7: Deploy Box to public testnet Ropsten.
When I am following Task 7.2: deploy Box V1, and try to deploy the code on sepolia/bnb test net. I received below error:
Error: Contract at 0xbE1d86B2e28e8214EeCE8f2292bd36D3a8bb2F42 doesn't look like an ERC 1967 proxy with a logic contract address.
The strange thing is that when I go to etherscan, the contract is there and it is indeed a proxy! I searched online and could not find out the reason. Please assist. Thank you!
Probably too late, since it's been two and a half months, but just created a dev account to respond to this hahaha
I had the same issue you did. This is because online testnets are not as fast as Hardhat's localhost testnet, therefore, even though you can see your contract in Etherscan and it is a perfectly normal proxy contract, the getImplementationAddress function run in your script won't be detected as such, since some block confirmations must be missing when the function get executed or something.
I was hoping to find a solution to this that used some sort of query to the blockchain in order to wait for everything to be in place before trying getImplementationAddress, but I couldn't find a way, so I just rewrote the script to try that line a maximum of ten times with a 1 second delay and that worked.
Hope this helps!