How to make smart contracts upgradeable without Proxy?
Thu, Nov 9, 2023 •10 min read
Category: Code Stories
The blockchain industry thrives on trustlessness, decentralization, and immutability. These principles are the cornerstones of its rapid growth in recent years. However, like any innovative field, blockchain technology is not without its challenges. One major challenge is the immutability of smart contracts—once deployed, they cannot be changed. But what if a smart contract contains a vulnerability that could lead to funds being lost, or if you simply want to enhance its functionality or optimize gas usage? In this article, I’ll explain the concept of Non-Proxy Upgradeability Pattern.
To be (upgradeable) or not to be?
Trustlessness, decentralization, security. Immutability. The beauty of the blockchain. This is why this cutting-edge technology has been growing so rapidly for the last couple of years and surely there are still a lot of new ideas and concepts waiting to be discovered. But there’s no innovation without mistakes and as always new technologies can suffer from infancy problems. For sure those problems should be solved, but how can they be solved once the code is already deployed and can’t be changed (immutability, right?)? Aren’t smart contracts (self-executing programs) immutable by design? And shouldn’t they be as simple as possible? Yeah, but what if the contract is vulnerable and can lead to funds lost? Shouldn’t a bug be fixed? Or what if we simply want to improve functionality or optimize gas usage?
Welcome to the Blockchain.
Clone vs Proxy
So many questions, so few answers? Not really! Fortunately, some Big Brains were also thinking about the Upgradeability Dilemma, and some solutions have already been found and developed. I’m not gonna present and explain all of them, but for context let’s take a look at two: the Proxy Patterns and the Clones.
Proxy Pattern
In this approach, a smart contract acts as a proxy (storage layer) that delegates all calls to another contract with the logic implementation (logic layer). The address of the logic implementation can be changed, enabling upgradability. The typical structure looks like this:
Clone Factory Pattern
In this method, each instance of the clone contact has an identical bytecode, so only one instance of the implementation (logic layer) needs to be deployed and all clone instances behave as proxies. This approach, often referred to as the "minimal proxy," is defined in the EIP-1167 standard.
Although the Clone Factory Pattern aims to clone contract functionality simply and cost-effectively, it can still raise questions about its "purity" since it relies on a "proxy" pattern.
Hold on, but what’s wrong with that? I assume you know what the gas is and why we need it on the Ethereum blockchain. Notably, the .transfer() and .send() functions, used for ETH transfers, come with a fixed 2300 gas stipend, which is non-adjustable. Furthermore, the delegatecall function, fundamental to proxy patterns, incurs a gas cost of 21,000 (as msg calls in the EVM have an inherent base fee of 21,000 gas, and for proxy cases, delegatecall is treated as a transaction). As a result, if the wallet uses .send() function to send the ETH to the Proxy it is not gonna work!
Cloning constructor to the rescue!
Alright, so what can we do about this? Ladies and gentlemen, let me introduce the Non-Proxy Upgradeability Pattern! This approach enables you to deploy a contract to a specific address and later, if necessary, replace its logic while preserving the contract's state and balance. Here's how it works in a few simple steps:
You need a factory that deploys contracts. This factory must have a fixed address, which can be achieved through deterministic deployment, e.g. via Hardhat extensions.
The Factory has a method that deploys static bytecode which is “clone-contract constructor” (CCC). This constructor once deployed asks the deployer (msg.sender) about initial parameters via STATICCALL. To deploy the clone the CREATE2 method is used, which means the address depends only on:
the deployer address - and this is a fixed factory address
a random salt
That means the address of the cloned contract depends only on the salt.
The CCC uses EXTCODECOPY to load the contract logic code to the memory and finishes the entire process with RETURN, which points to the freshly cloned code.
Call from the Factory contract Init() function to use the calldata (which was passed with STATICCALL from step 2) and initialize the contract-clone.
Magic! The code was cloned without a proxy pattern!
Alright, so how can we upgrade the contract code if we don’t have a proxy? To achieve this we need two separate transactions because the contract's balance (as a result of the SELFDESTRUCT) is sent at the end of tx. So here it is:
Clone-contract uses the SELFDESTRUCT opcode to clear all the contract data and sends the factory address as the contract's balance receiver.
The factory finishes the upgrade by calling the finalizeUpgrade() function, which copies the new contract implementation the same way as it was done at the creation step 1 and sends all the native coin amounts received in the previous step.
And that’s it!
OPCODES, slots, and EVM Storage
If you want to learn more, let me show you some code behind the magic explained above.
call templateAddress() on msg.sender and store it under storage slot 0x1234
PUSH1 0x80
RETURNDATASIZE
DUP2
DUP2
PUSH4 0x0c5a6f2f // sighash of templateAddress()
PUSH1 0xe0
SHL // SHL 224 bits to the left
PUSH1 0x00
MSTORE // MSTORE(0x, 0x0c5a6f2f) - store sighash to use it for STATICCALL
…
PUSH2 0x1234
SSTORE // store templateAddress() value under storage slot 0x1234
copy contract's code to memory
PUSH1 0x00
MLOAD
EXTCODESIZE // EXTCODESIZE(templateAddress)
DUP1 // duplicate code size and put on stack for last RETURN
PUSH1 0x00
DUP1
DUP1
MLOAD
EXTCODECOPY // EXTCODECOPY
copy contract's code to memory
PUSH1 0x00
MLOAD
EXTCODESIZE // EXTCODESIZE(templateAddress)
DUP1 // duplicate code size and put on stack for last RETURN
PUSH1 0x00
DUP1
DUP1
MLOAD
EXTCODECOPY // EXTCODECOPY
return and point to the copied code
PUSH1 0x00
RETURN
In Solidity it can be implemented like this:
/**
* @dev Deploy static bytecode which is "contract-clone constructor"
* it calls factory to copy a code deployed under templateAddress()
* @param _salt It's used to determine the final contracts address
*/
function _deployContractClone(bytes32 _salt) internal returns (address instance) {
// set template address to be read from templateAddress()
template = upgradeableContractData[_salt].template;
assembly {
let ptr := mload(0x40)
mstore(ptr, 0x60803d8181630c5a6f2f60e01b600052335afa600051611234556000513b8060)
mstore(add(ptr, 0x20), 0x008080513c6000f3000000000000000000000000000000000000000000000000)
instance := create2(0, ptr, 0x28, _salt)
}
require(instance != address(0), "ERC1167: create2 failed");
upgradeableContractData[_salt].instance = instance;
template = address(0);
}
Conclusion
The Non-Proxy Upgradeability Pattern offers an alternative path to upgrade smart contracts without relying on a proxy pattern. By addressing gas costs and enabling deterministic deployments, it opens up new possibilities for blockchain developers to enhance contract functionality and security. This approach exemplifies the ever-evolving nature of blockchain technology, where challenges lead to innovative solutions.
I really hope this blog post will help those of you struggling with creating upgradeable contracts. If you have any questions or would like to discuss the methodology - drop us a line at hello@rumblefish.dev. In the meantime, stay tuned for more coding stories!