How to use hardhat-upgrades for a complex contract

Cau Ta
Crypitor Service.
Published in
4 min readJan 24, 2024

Created by: Cau Ta

Created time: January 10, 2024 3:32 PM

Photo by Nenad Novaković on Unsplash

Introduction

In this article, we will explore how to use hardhat-upgrades to work with a complex contract. Specifically, we will focus on a marketplace nft contract that is upgradeable using hardhat-upgrades. We will discuss the requirements, deployment process, and the benefits of using hardhat-upgrades in unit testing.

The Marketplace Contract

Before diving into the details, let’s take a look at the marketplace contract that we will be working with. You can find the contract here. It is an upgradeable contract, meaning that it can be upgraded using hardhat-upgrades.

Deploying an Upgradeable Proxy

Set up a project with hardhat-upgrade plugin document: https://docs.openzeppelin.com/upgrades-plugins/1.x/hardhat-upgrades

To deploy the upgradeable proxy, we will use the deployProxy function provided by hardhat-upgrades. Here is a sample code snippet:

async function deployProxy(
Contract: ethers.ContractFactory,
args: unknown[] = [],
opts?: {
initializer?: string | false,
unsafeAllow?: ValidationError[],
constructorArgs?: unknown[],
initialOwner?: string,
timeout?: number,
pollingInterval?: number,
redeployImplementation?: 'always' | 'never' | 'onchange',
txOverrides?: ethers.Overrides,
kind?: 'uups' | 'transparent',
useDefenderDeploy?: boolean,
},
): Promise<ethers.Contract>

Note that our marketplace contract has constructor arguments that need to be satisfied. Additionally, it has an immutable variable in the implementation contract. To handle these requirements, we will need to make some modifications. Here is an example of how we can deploy the marketplace using hardhat-upgrades:

import { deployments, ethers, upgrades } from "hardhat";

...

const market = await upgrades.deployProxy(Marketplace, [
ownerAddress, "", [], ownerAddress, 0
], {
unsafeAllow: ["state-variable-immutable", "constructor"],
constructorArgs: [await wrapToken.getAddress()]
});
await market.waitForDeployment();
marketplace = market as unknown as Marketplace;

In the above code, we allow unsafe conditions and embed the constructor arguments in the implementation contract. After deployment, we have a global marketplace variable let marketplace: Marketplace;. However, since the market variable is of unknown type, we need to convert it to the Marketplace type for use in our tests.

There are 2 important things can be misunderstood here:

  1. The second argument of function deployProxy is represented for the arguments of initialize function. The Upgradeable contract will be created and initialize in the same transaction. The constructor code below has a modifier call initializer, that mean after create the contract, you can not call initialize again. That’s why we need to call initialize when creating contract.
  2. the constructorArgs in deploy options. If the implement logic contract has a constructor, this is the place you define the constructor arguments
    constructor(address _nativeTokenWrapper) initializer {
nativeTokenWrapper = _nativeTokenWrapper;
}

/// @dev Initializes the contract, like a constructor.
function initialize(
address _defaultAdmin,
string memory _contractURI,
address[] memory _trustedForwarders,
address _platformFeeRecipient,
uint256 _platformFeeBps
) external initializer {
// Initialize inherited contracts, most base-like -> most derived.
__ReentrancyGuard_init();
__ERC2771Context_init(_trustedForwarders);

// Initialize this contract's state.
timeBuffer = 15 minutes;
bidBufferBps = 500;

contractURI = _contractURI;
platformFeeBps = uint64(_platformFeeBps);
platformFeeRecipient = _platformFeeRecipient;

_setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin);
_setupRole(LISTER_ROLE, address(0));
_setupRole(ASSET_ROLE, address(0));
}

Why we need unsafeAllow?

According to Openzeppelin document the upgradeable contract can not have constructor. They provide a function called “initialize” to init contract arguments and state. Hardhat upgrade did a verification check with constructor, and mark it unsafe.

Another thing we have to consider is “immutable-variable”

  /// @dev The address of the native token wrapper contract.
address private immutable nativeTokenWrapper;

Because the marketplace contract has an immutable variable represent for nativeTokenWrapper address, so after verification step hardhat upgrade also throw an error. But why?

The concept of upgradeable contract is allow you to control the logic from Upgradeable contract, if you define a variable that immutable at the implementation level, your contract can not change or update it, that’s why hardhat upgrade mark it unsafe

Benefits of Using Hardhat-Upgrades in Unit Testing

You might wonder why we have to use hardhat-upgrades instead of directly using the implementation contract itself for unit testing. The reason is that the Marketplace contract does not allow running the initialize function after the constructor. Therefore, if we deploy the marketplace itself, we cannot run initialize to initialize our contract. By using the upgradeable version provided by hardhat-upgrades, we can leverage the init data that calls the initialize function.

Conclusion

In this article, we have explored how to use hardhat-upgrades to work with a complex contract, specifically focusing on a marketplace contract. We have discussed the deployment process, modifications needed to satisfy requirements, and the benefits of using hardhat-upgrades in unit testing.

Happy coding!

--

--

Cau Ta
Crypitor Service.

Blockchain expert, Cryptos Researcher, Software Engineering. Founder Crypitor Service.