Upgrading a contract via a multisig

This guide will walk you through the process of upgrading a smart contract in production secured by a multisig wallet, using Defender Admin as an interface, and Hardhat scripts behind the scenes. Specifically, we will:

  • Write and deploy an upgradeable contract using the Upgrades Plugin for Hardhat

  • Transfer upgrade rights to a multisig wallet for additional security

  • Validate, deploy, and propose a new implementation using Hardhat

  • Execute the upgrade through the multisig in Defender Admin

What is an upgrade?

Smart contracts in Ethereum are immutable by default. Once you create them there is no way to alter them, effectively acting as an unbreakable contract among participants.

Upgradeable contracts allow us to alter a smart contract to fix a bug, add additional features, or simply to change the rules enforced by it.

This allows us to change the contract code, while preserving the state, balance, and address.

Process of upgrading

The upgrade admin account (the owner of the ProxyAdmin contract) is the account with the power to upgrade the upgradeable contracts in your project. The default owner is the externally owned account used to deploy the contracts. Whilst this may be good enough for a local or testnet deployment, in production you need to better secure your contracts. An attacker who gets hold of your upgrade admin account can change any upgradeable contract in your project!

It is recommended to change the ownership of the ProxyAdmin after deployment to a multisig, requiring multiple owners to approve a proposal to upgrade.

The process of creating an upgradeable contract and later upgrading is as follows:

  1. Create upgradeable contract. Development should include appropriate testing and auditing.

  2. Deploy upgradeable contract. Deployment consists of implementation contract, ProxyAdmin and the proxy contract using OpenZeppelin Upgrades Plugins for Hardhat with a developer controlled private key.

  3. Transfer control of upgrades (ownership of the ProxyAdmin) to a multisig. This means we can no longer upgrade locally on our machine.

  4. (After a period of time) Create a new version of our implementation. Development should include appropriate testing and auditing.

  5. Propose the upgrade. This checks the new implementation for upgrade safety, deploys the contract and creates a proposal.

  6. Upgrade the contract. The required number of owners of the multisig need to approve and finally execute the upgrade.

Prerequisites

To get started, you’ll need the following:

  1. A Defender account. Head over to Defender to sign up for a new account.

  2. ETH to pay for transactions gas. Transactions require gas for execution, so make sure to have some ETH available. For this guide we will use Rinkeby ETH.

  3. Multi Sig. A multisig contract to control our upgradeable contract. In this guide we will use a Gnosis Safe but you could also use any supported multisig such as a legacy Gnosis MultiSigWallet. Create a Gnosis Safe multisig on the Rinkeby network, with M > N/2 and M > 1. This should be at least 2 of 3. (See Advisor for guidance on multisig best practices).

  4. Hardhat project. A Hardhat project with Hardhat Upgrades plugin, Hardhat Defender, ethers.js and dotenv installed.

$ npm install --save-dev @openzeppelin/hardhat-upgrades @openzeppelin/hardhat-defender @nomiclabs/hardhat-ethers ethers dotenv

Create upgradeable contract

The first step will be to create an upgradeable contract. In this guide we will use the Box.sol contract from the OpenZeppelin Learn guides. Create a contracts directory in our project root and then create Box.sol in the contracts directory with the following Solidity code.

Upgradeable contracts use initialize functions rather than constructors to initialize state. To keep things simple we will initialize our state using the public store function that can be called multiple times from any account rather than a protected single use initialize function.
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.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;
    }
}

Deploy upgradeable contract

To deploy our contract we will use a script. The Hardhat Upgrades plugin provides a deployProxy function to deploy our upgradeable contract. This deploys our implementation contract, a ProxyAdmin (the admin for our projects proxies) and the proxy, along with calling any initialization.

Create a scripts directory in our project root and then create the following deploy.js script in the scripts directory.

In this guide we don’t have an initialize function so we will initialize state using the store function.

// scripts/deploy.js
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 deployed to:', box.address);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

We would normally test and then deploy to a local test network and manually interact with it. For the purposes of the guide we will skip ahead to deploying to a public test network.

In this guide we will deploy to Rinkeby as Gnosis Safe supports Rinkeby testnet. If you need assistance with configuration, see Connecting to public test networks and Hardhat: Deploying to a live network.

We can create a .env file to store our mnemonic and provider API key. You should add .env to your .gitignore.

MNEMONIC="Enter your seed phrase"
ALCHEMY_API_KEY="Enter your Alchemy API Key"
DEFENDER_TEAM_API_KEY="Enter your Defender Team API Key"
DEFENDER_TEAM_API_SECRET_KEY="Enter your Defender Team API Secret"
Any secrets such as mnemonics or API keys should not be committed to version control.

We will use the following hardhat.config.js for deploying to Rinkeby.

In this guide we will use Alchemy, though you can use Infura, or another public node provider of your choice to connect to the network.
// hardhat.config.js
require('dotenv').config();
require('@nomiclabs/hardhat-ethers');
require('@openzeppelin/hardhat-upgrades');

const mnemonic = process.env.MNEMONIC;
const alchemyApiKey = process.env.ALCHEMY_API_KEY;

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
module.exports = {
  networks: {
    rinkeby: {
      url: `https://eth-rinkeby.alchemyapi.io/v2/${alchemyApiKey}`,
      accounts: { mnemonic },
    },
  },
  solidity: '0.7.3',
};

Run our deploy.js and deploy to the Rinkeby network. Our implementation contract, a ProxyAdmin and the proxy will be deployed.

We need to keep track of our proxy address, we will need it later.
$ npx hardhat run --network rinkeby scripts/deploy.js
Compiling 2 files with 0.7.3
Compilation finished successfully
Deploying Box...
Box deployed to: 0x5C1e1732274630Ac9E9cCaF05dB09da64bE190B5

Transfer control of upgrades to a multisig

We will use a multisig to control upgrades of our contract. Defender Admin supports Gnosis Safe and the legacy Gnosis MultiSigWallet.

The admin (who can perform upgrades) for our proxy is a ProxyAdmin contract. Only the owner of the ProxyAdmin can upgrade our proxy.

Ensure to only transfer ownership of the ProxyAdmin to an address we control.

Create transfer-ownership.js in the scripts directory with the following JavaScript. Change the value of gnosisSafe to your Gnosis Safe address.

// scripts/transfer-ownership.js
async function main () {
  const gnosisSafe = '0xFb2C6465654024c03DC564d237713F620d1E9491';

  console.log('Transferring ownership of ProxyAdmin...');
  // The owner of the ProxyAdmin can upgrade our contracts
  await upgrades.admin.transferProxyAdminOwnership(gnosisSafe);
  console.log('Transferred ownership of ProxyAdmin to:', gnosisSafe);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

We can run the transfer ownership code on the Rinkeby network.

$ npx hardhat run --network rinkeby scripts/transfer-ownership.js
Transferring ownership of ProxyAdmin...
Transferred ownership of ProxyAdmin to: 0xFb2C6465654024c03DC564d237713F620d1E9491

Create a new version of our implementation

After a period of time, we decide that we want to add functionality to our contract. In this guide we will add an increment function to our Box contract.

We cannot make arbitrary changes to our contract, see Upgrading for more details on what modifications are valid.

Create the new implementation, BoxV2.sol in your contracts directory with the following Solidity code.

// contracts/BoxV2.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;

import "./Box.sol";

contract BoxV2 is Box {
    // Increments the stored value by 1
    function increment() public {
        store(retrieve() + 1);
    }
}
To test our upgrade we should create unit tests for the new implementation contract, along with creating higher level tests for testing interaction via the proxy, checking that state is maintained across upgrades. See OpenZeppelin Upgrades: Step by Step Tutorial for Hardhat for example tests.

Create Defender Team API key

In order to create Defender Admin proposals via the API we need a Team API key.

To obtain a key, from the Defender menu in the top right corner select Team API Keys and then select Create API Key. We only need Create Admin proposals and contracts capabilities, so select this and set an optional note to describe the key.

Defender new Team API Key

We can then copy and store our API Key and the Secret Key in our projects .env file.

We won’t be able to retrieve our Secret Key from Defender again. Instead we would need to create a new Team API Key.

Propose the upgrade

Once we transferred control of upgrades (ownership of the ProxyAdmin) to our multisig, we can no longer simply upgrade our contract. Instead we need to first propose an upgrade that the owners of the multisig can review and once reviewed approve and execute the proposal to upgrade the contract.

To propose the upgrade we use the Defender plugin for Hardhat.

We need to register the Hardhat Defender plugin in our hardhat.config.js

require("@openzeppelin/hardhat-upgrades");

We also need to add our Defender Team API key to the exported configuration in hardhat.config.js:

module.exports = {
  defender: {
    apiKey: process.env.DEFENDER_TEAM_API_KEY,
    apiSecret: process.env.DEFENDER_TEAM_API_SECRET_KEY,
  }
}

Our hardhat.config.js should then look as follows:

// hardhat.config.js
require('dotenv').config();
require('@nomiclabs/hardhat-ethers');
require('@openzeppelin/hardhat-upgrades');
require('@openzeppelin/hardhat-defender');

const mnemonic = process.env.MNEMONIC;
const alchemyApiKey = process.env.ALCHEMY_API_KEY;

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
module.exports = {
  defender: {
    apiKey: process.env.DEFENDER_TEAM_API_KEY,
    apiSecret: process.env.DEFENDER_TEAM_API_SECRET_KEY,
  },
  networks: {
    rinkeby: {
      url: `https://eth-rinkeby.alchemyapi.io/v2/${alchemyApiKey}`,
      accounts: { mnemonic },
    },
  },
  solidity: '0.7.3',
};

Once we have setup our configuration we can propose the upgrade. This will validate that the implementation is upgrade safe, deploy our new implementation contract and propose an upgrade.

Create propose-upgrade.js in the scripts directory with the following code.

We need to update the script to specify our proxy address
// scripts/propose-upgrade.js
const { defender } = require("hardhat");

async function main() {
  const proxyAddress = '0x5C1e1732274630Ac9E9cCaF05dB09da64bE190B5';

  const BoxV2 = await ethers.getContractFactory("BoxV2");
  console.log("Preparing proposal...");
  const proposal = await defender.proposeUpgrade(proxyAddress, BoxV2);
  console.log("Upgrade proposal created at:", proposal.url);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  })

We can then run the script on the Rinkeby network to propose the upgrade.

$ npx hardhat run --network rinkeby scripts/propose-upgrade.js
Compiling 1 file with 0.7.3
Compilation finished successfully
Preparing proposal...
Upgrade proposal created at: https://defender.openzeppelin.com/#/admin/contracts/rinkeby-0x5C1e1732274630Ac9E9cCaF05dB09da64bE190B5/proposals/bd8ab482-2c12-47f9-8052-d0b77a7313dc

Upgrade the contract

Once we have proposed the upgrade, the owners of the multisig can review and approve it using Defender Admin. Using the link from propose-upgrade.js each member of our team can review the proposal in Defender. The required number of owners of the multisig can approve the proposal and then finally execute to upgrade our contract.

Defender Upgrade Proposal

We can see the executed upgraded proposal in our list of proposals in Defender Admin and our contract has been upgraded.

Defender Proposals

Wrapping up

Let’s recap the steps we’ve just gone through:

  • Wrote and deployed an upgradeable contract

  • Transferred upgrade rights for our upgradeable contract to a multisig wallet

  • Validated, deployed, and proposed a new implementation

  • Executed the upgrade proposal through the multisig in Defender Admin

Controlling upgrade rights with a multisig better secures our upgradeable contracts. Along with using Defender Admin to better manage the upgrade process.

Questions

If you have any questions or comments, don’t hesitate to ask on the forum!