Skip to main content

Command Palette

Search for a command to run...

How to Safely Verify Ethereum Contract Upgrades

Updated
4 min read
How to Safely Verify Ethereum Contract Upgrades

Here we will go through the process of contract upgrades. I assume you are already familiar with proxy patterns in Ethereum. No matter which libraries or frameworks you use, every upgrade ends with the same critical action: executing the method that replaces the old implementation contract address with the new one. This step must be reviewed by you, your team, and ideally by external reviewers as well.

Before we get to the verification flow, here is a short overview of how we develop, deploy, and upgrade our contracts.

Contracts: Develop, Deploy, Upgrade

We use the 0xweb stack of libraries: 0xweb, 0xweb-hardhat, dequanto

This gives us the following workflow:

  1. Write Solidity contracts as usual.
  2. Run npx hardhat compile --watch. The 0xweb plugin compiles the Solidity files and generates, for each contract, a TypeScript or JavaScript client class. Each generated class encapsulates the blockchain interaction layer and exposes a clean interface.

The --watch flag triggers recompilation as soon as you save modified Solidity files.

Each generated class is linked to the Hardhat artifacts, which are used by the deployment factory:

import { MyFooContract } from '@0xc/hardhat/MyFooContract/MyFooContract';
import { Deployments } from 'dequanto/contracts/deploy/Deployments';
import { Web3ClientFactory } from 'dequanto/clients/Web3ClientFactory';

let client = await Web3ClientFactory.getAsync('eth');
let deployments = new Deployments(client, deployer, {
    directory: './deployments/',
    whenBytecodeChanged: 'redeploy'
});

let { contract: foo } = await deployments.ensure(MyFooContract, {
    arguments: someConstructorArgs
});

// Read method
let someValue = await foo.getSomeValue();
// Write method
let tx = await foo.$receipt().setSomeValue(deployer, 123);

The ensure method checks whether the contract is already deployed on the selected network and whether the bytecode matches. If the bytecode is unchanged, it returns a ready-to-use instance; otherwise, it deploys the contract. After deployment it also verifies the contract on the blockchain explorer (for Ethereum this is Etherscan).

This works well for stateless contracts. For stateful contracts you usually need upgradeable proxies, so that the storage stays intact while the implementation changes. The deployment factory supports TransparentUpgradeableProxy through the ensureWithProxy method:

let { contract: foo } = await deployments.ensureWithProxy(MyFooContract, {
    arguments: someConstructorArgs,
    initialize: someInitializerArgs
});

Here, the factory compares the deployed bytecode with the current build. If there are changes, it redeploys the implementation contract and submits an upgradeToAndCall transaction.

Important: the ProxyAdmin should not be your deployer account. It should be a Timelock, or at least a Multisig. A safe setup looks like this:

MyFooContractProxyTimelockMultisig

This ensures that upgrade transactions cannot be executed immediately. Dequanto supports many account types (Timelock, Multisig, ERC-4337), but we will not go into those details here.

The key point is: you now have a pending upgrade transaction that will replace the implementation of your proxy. Before approving it, you must compare the old and new implementations. Below are the steps we use.


Implementation Comparison Workflow

1. Check out the exact commit used for deployment

Every deployment must correspond to a specific git commit. If you don't have it locally, fetch it with minimal history:

git clone --no-checkout my-repo
cd my-repo
git fetch --depth 1 origin <commit-hash>
git checkout <commit-hash>

You now have the exact sources that were used for deployment.

2. Download verified sources for the old and new implementations

npm i 0xweb -g
0xweb i <old-implementation-address> --chain eth --name myFooV1
0xweb i <new-implementation-address> --chain eth --name myFooV2

After this step, you have three sets of sources:

  • repository sources from the referenced commit,
  • verified sources for the old implementation,
  • verified sources for the new implementation.

3. Compare the new implementation against the old one

git diff --no-index ./0xc/eth/myFooV1/myFooV1/contracts/ ./0xc/eth/myFooV2/myFooV2/contracts/

This diff shows exactly what changed between versions.

4. Compare repository sources against the new implementation

git diff --no-index --diff-filter=DM ./0xc/eth/myFooV2/myFooV2/contracts/ ./my-repo/contracts/

We use the DM filter to show only deleted and modified files, because the repository will often include extra files that are not part of the verified sources.

The goal here is simple: the output should be empty. If there is no diff, then the verified implementation matches your codebase.


These two comparisons are essential. Each party involved in the upgrade process should review them before signing and executing the upgrade transaction through the multisig.