Is it possible to create upgradeable Minimal Proxy Clone contracts?

Short answer: no, but Upgradeable Beacon Proxies might be what you are looking for

Is it possible to create upgradeable Minimal Proxy Clone contracts?

How did we get here?

The conversation goes somewhat like this:
- PM: Can we create multiple copies of the same contract without redeploying the same one over and over? We can save a lot of gas costs in that way.
You, savvy Solidity developer who did his homework, answer confidently:
- 0x9: Sure, there is EIP1167, the Minimal Proxy Contract (Minimal Clones), that allows us to deploy proxies pointing to an implementation address with minimal cost.
- PM: That sounds perfect, but if we are using a Proxy, then that means that those Clone contracts will still be upgradeable right?
Wrong, your soul sinks into an abysm of despair.
- 0x9: Erm...not really, in Minimal Proxy contract the implementation address is hardcoded into the bytecode, so you can't just change it without breaking the standard. Plus, even if you could, you would have to go over every Clone changing the address (and I would rather not do that).
- PM: I see, then please get me the best option to implement an Upgradeable Factory pattern while keeping the ability to upgrade all the copies at the same time.

That is more or less how it goes. You are left alone in your home office to speak with your rubber duck about how you can figure this out.

How to create multiple proxies pointing to the same implementation?

This one is the easiest part (but spoiler, it won't be enough)You can create multiple copies of a Universal Upgradeable Proxy Standard (UUPS) or a Transparent Proxy. Since we have been told to get the most gas-efficient solution possible we will go with UUPS, since Transparent Proxies are more expensive to deploy and operate.

In UUPS the upgrade logic is handled by the implementation contract. This means that the contract holding the logic must include an upgradeTo(newImplementation address) that will update the implementation address in storage, therefore changing where the Proxy directs its delegatecall() function.

Before we continue: project setup

I am assuming that you know how to set up a Solidity project so I will skip all of those steps. I normally use Foundry so if you want to follow along you can either go to the article's repo or set up a new project through my Foundry template.

How do we create multiple copies of the contract?

  1. Implementation contract

    Once we deploy our implementation contract, we deploy an ERC1967 Proxy that points to this implementation. Since our that contract inherits from UUPSUpgradeable, the upgrade() logic resides there. We are going to implement 2 versions of an NFT contract, V1 only has a mint()function while V2 also includes a burn()function. Most of the initial parameters for a standard NFT contract are defined within the initialize() function.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin-contracts-upgradeable/contracts/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import "../interfaces/Inft.sol";


error MintPriceNotPaid();
error SupplyExceeded();

contract NFTV1UUPS is INFT, ERC721Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
    uint256 public maxSupply;
    uint256 public price;
    uint256 public tokenId;
    string public baseTokenURI;

    constructor() {
        _disableInitializers();
    }

    function initialize(
        string memory name,
        string memory symbol,
        string memory baseURI,
        uint256 initialMaxSupply, 
        uint256 initialPrice,
        address owner
    ) external initializer {

        __ERC721_init_unchained(name, symbol);
        __Ownable_init_unchained();

        baseTokenURI = baseURI;
        maxSupply = initialMaxSupply;
        price = initialPrice;
        transferOwnership(owner);
    }

    function mint(address to, uint256 amount) external payable onlyOwner {
        uint256 currentId = tokenId;

        if(currentId + amount > maxSupply) revert SupplyExceeded();
        if(msg.value != price * amount) revert MintPriceNotPaid();

        tokenId += amount;

        for (uint256 i= 0; i < amount; i++){
            _mint(to, currentId + i);
        }
    }

    function withdraw() external onlyOwner {
     uint256 balance = address(this).balance;
     require(balance > 0, "No ether left to withdraw");

     (bool success, ) = payable(owner()).call{value: balance}("");

     require(success, "Transfer failed.");
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}

    function _baseURI() internal view override returns (string memory) {
       return baseTokenURI;
    }
}
  1. Factory contract

    Now to generate multiple "copies" of the implementation contract, we can create a Factory contract that will generate a new ERC1967 Proxy each time we call our createNft() function. We are using the CREATE2 opcode to generate the contract addresses deterministically.

    Each time we create a new contract we increment an id counter. This counter will allow us to:

    1. Know how many copies of our contract exist.

    2. Fetch the address of any of those copies according to their id.

    3. Use it as the saltvalue that lets us create the contract with a deterministic address.

If you add the {salt:bytes32(ID)}param when you create a new contract from another contract, the compiler will interpret that you intend to use CREATE2 instead of CREATE.

Also, note that the best practice is to include the ABI-encoded call to the initialize function in the _data argument within the constructor call to the ERC1967 Proxy as per the Openzeppelin docs to avoid leaving the Proxy uninitialized. However, we are doing it in a separate call within the same function. Otherwise, we would need to know the initial deployment arguments when trying to reconstruct the newly deployed contract address.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "../interfaces/Inft.sol";

error NonExistingProxy();
error ZeroAddress();
contract UUPSProxyFactory is Ownable {
    uint256 private _proxyId;
    address private _implementation;

    constructor (address implementation) {
        _implementation = implementation;
    }

    function createNewProxy(
        string memory name, 
        string memory symbol, 
        string memory baseURI, 
        uint256 maxSupply, 
        uint256 price
    ) external returns (address) {
        ERC1967Proxy proxy = new ERC1967Proxy{salt: bytes32(_proxyId)}(_implementation, "");
        INFT inft = INFT(address(proxy));
        inft.initialize(name, symbol, baseURI, maxSupply, price, msg.sender);
        _proxyId++;
        return address(proxy);
    }

    function setImplementation(address implementation) external onlyOwner {
        if(implementation == address(0)) revert ZeroAddress();
        _implementation = implementation;
    }

    function getProxyAddress(uint256 proxyId) external view returns (address) {
        if (proxyId > _proxyId) revert NonExistingProxy();

        bytes memory bytecode = type(ERC1967Proxy).creationCode;
        bytecode = abi.encodePacked(bytecode, abi.encode(_implementation,""));
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff),
                address(this),
                proxyId,
                keccak256(bytecode)
            )
        );

        return address(uint160(uint256(hash)));
    }

    function getCurrentProxyId() external view returns (uint256) {
        return _proxyId;
    }
}

Why this is not enough? Simultaneous Upgrades

I am sure by now you were pulling your hair out. You got it wrong! That's not how it works! Well yeah, my internet frens but this is a walkthrough on the thought process of how to solve a given problem, as well as a code tutorial.

Why our current solution won't work? Because, one of the key requirements that we were given was to be able to simultaneously upgrade all the proxy copies of our contract. Neither UUPS nor Transparent proxies can do that because the address of the implementation contract lives on the storage of each instance of the proxy (even though the logic on how to upgrade it lives in different places depending on the standard).

So how can we update the implementation address without going through each one of the proxies that our factory contract has generated? We use a Beacon Proxy.

Beacon proxy

In the Beacon proxy pattern, the implementation address is stored in a special contract, called precisely a Beacon, and every proxy checks against it what is the current implementation address before performing the delegateCall .

This makes it more costly to deploy than UUPS, as we will be deploying 3 contracts initially (implementation, Beacon, and proxy factory) but it checks all the boxes of our requirements as we only need to change the implementation address stored in the Beacon and then all of our proxy copies will begin forwarding their calls to the new logic contract. Note that this extra call also makes it a bit more expensive to use than UUPS, as we need more gas to go through the whole process.

This is what it looks like:

On the implementation side, NFTV1 doesn't inherit from UUPSUpgradeable anymore.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin-contracts-upgradeable/contracts/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol";
import "./interfaces/Inft.sol";


contract NFTV1 is INFT, ERC721Upgradeable, OwnableUpgradeable {
    uint256 public maxSupply;
    uint256 public price;
    uint256 public tokenId;
    string public baseTokenURI;

    error MintPriceNotPaid();
    error SupplyExceeded();

    constructor() {
        _disableInitializers();
    }

    function initialize(
        string memory name,
        string memory symbol,
        string memory baseURI,
        uint256 initialMaxSupply, 
        uint256 initialPrice,
        address owner
    ) external initializer {

        __ERC721_init_unchained(name, symbol);
        __Ownable_init_unchained();

        baseTokenURI = baseURI;
        maxSupply = initialMaxSupply;
        price = initialPrice;
        transferOwnership(owner);
    }

    function mint(address to, uint256 amount) external payable onlyOwner {
        uint256 currentId = tokenId;

        if(currentId + amount > maxSupply) revert SupplyExceeded();
        if(msg.value != price * amount) revert MintPriceNotPaid();

        tokenId += amount;

        for (uint256 i= 0; i < amount; i++){
            _mint(to, currentId + i);
        }
    }

    function _baseURI() internal view override returns (string memory) {
       return baseTokenURI;
    }

    function withdraw() external onlyOwner {
     uint256 balance = address(this).balance;
     require(balance > 0, "No ether left to withdraw");

     (bool success, ) = payable(owner()).call{value: balance}("");

     require(success, "Transfer failed.");
    }
}

On the Factory contract, we are not deploying ERC1967 Proxies anymore, but Beacon Proxies.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./interfaces/Inft.sol";

contract BeaconProxyFactory is Ownable {
    uint256 private _proxyId;
    address private _beacon;

    constructor (address beacon) {
        _beacon = beacon;
    }

    function createNewProxy(
        string memory name, 
        string memory symbol, 
        string memory baseURI, 
        uint256 maxSupply, 
        uint256 price
    ) external returns (address) {
        BeaconProxy proxy = new BeaconProxy{salt: bytes32(_proxyId)}(_beacon, "");
        INFT inft = INFT(address(proxy));
        inft.initialize(name, symbol, baseURI, maxSupply, price, msg.sender);
        _proxyId++;
        return address(proxy);
    }
    function getProxyAddress(uint256 proxyId) external view returns (address) {
        bytes memory bytecode = type(BeaconProxy).creationCode;
        bytecode = abi.encodePacked(bytecode, abi.encode(_beacon,""));
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff),
                address(this),
                proxyId,
                keccak256(bytecode)
            )
        );

        return address(uint160(uint256(hash)));
    }
    function getCurrentProxyId() external view returns (uint256) {
        return _proxyId;
    }
}

Don't get confused by the contract's naming. If you are using Openzeppelin's contracts, Beacon Proxy is the proxy that looks up the implementation address in the Beacon contract, and that Beacon contract is called UpgradeableBeacon . You can see it clearly in the deployment script.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

import {Script, console2} from "forge-std/Script.sol";
import "../src/NFTV1Implementation.sol";
import "../src/BeaconProxyFactory.sol";
import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";


contract Deploy is Script {
    function setUp() public {
    }

    function run() public {
        vm.startBroadcast();

        NFTV1 implementationV1 = new NFTV1();
        console2.log("Deployed NFTV1 Implementation at address: %s", address(implementationV1));

        UpgradeableBeacon beacon = new UpgradeableBeacon(address(implementationV1));

        BeaconProxyFactory beaconProxyFactory = new BeaconProxyFactory(address(beacon));

        vm.stopBroadcast();
    }
}

Conclusion

As we mentioned before, if we deploy a new version of our implementation, like our NFTV2 which includes a burn()functionality, we can just change the current address stored in UpgradeableBeacon and all the deployed proxies will acquire the new functionality. Be mindful of security on your upgrades as you can also potentially include a bug in all the copies of your contract at once.

References