Ethereum Series: Cloning contracts with ERC-1167
How to save time and gas fees for smart contract developers
Note: This article is relatively technical, so prepare to brave through Solidity design patterns and codes.
Contents
ERC-1167: Minimal Proxy Contract
2.1 Why it matters?
3.1 Implementation
4.1 Compatibility
4.3 Storage layout
4.4 Constructors
1. Introduction
For Solidity developers, deploying the same smart contract multiple times is a huge pain. It’s tedious and expensive.
Suppose you need to deploy a new wallet whenever a user signs up to your blockchain-based app. While each new deployment have a different storage (e.g. fees, funds, subscription plan), the logic of the contract remains the same across all wallets. So then, how do you reuse this logic and deploy another standalone contract to accommodate a new storage?
Instead of deploying the entire contract code every time a user onboards, there is a more efficient and somewhat underrated ‘trick’. It is a standard called ERC-1167, also known as Minimal Proxy Contract.
Formerly known as EIP-1167 (before the proposal was authorised by the Ethereum community), the Minimal Proxy Contract offers a practical solution for cloning smart contracts on Ethereum. It addresses some key challenges for developers:
code reusability
gas efficiency
security
In this article, we cover how ERC-1167 works as well as its benefits and drawbacks. The final section showcases our proof-of-concept for this standard.
2. ERC-1167: Minimal Proxy Contract
Simply put, the ERC-1167 standard clones contract functionalities in immutable way. It is designed to create lightweight proxy contracts that can be used to delegate functionality and save gas costs on the Ethereum network. The idea behind a proxy contract is to have a small standalone contract that delegates its functionality to the larger main contract, which is known at a fixed address. This works because all the proxy contracts do is delegatecall
to a main contract.
Essentially, we:
deploy one instance of the main contract
deploy multiple minimal proxies pointing towards the main contract address
initialise accordingly (if necessary)
2.1 Why it matters?
Instead of redeploying an entire contract, developers can now duplicate a contract with exact functionalities using a minimal bytecode. This minimal proxy contract is deployed once, and can be used to delegate to the main contract. This saves time and deployment gas cost, especially for complex contracts.
Some major benefits of ERC-1167:
Code reusability: Since developers can deploy multiple instances of a contract, it helps them to build upgradeable contracts without having to redeploy the entire contract each time an upgrade is needed. This can be particularly useful for contracts that share common functionality, such as token contracts.
Gas efficiency: Traditional contract upgrades on Ethereum require deploying an entirely new contract and migrating all the data to the new contract. This process can be costly in terms of gas fees and time. With ERC-1167, upgrades are performed by deploying a new version of the logic contract and updating the proxy to point to the new logic. This approach significantly reduces gas costs compared to full contract redeployments.
Security and auditability: Since the proxy contract itself remains unchanged during upgrades, it can undergo extensive security audits and formal verification. This provides a higher level of confidence in the contract's behaviour. The separation of logic and proxy also makes it easier to review and analyse the contract code, as it eliminates the need to review the entire codebase during upgrades.
Bear in mind that although the proxy itself is not intrinsically upgradeable, it serves as the basis for proxy patterns used for upgrade contracts. It is possible to upgrade the underlying contract without having to redeploy all the proxies. This can make contract upgrades faster, cheaper, and less risky.
Ultimately, ERC-1167 minimises trust. The address in the proxy is hardcoded in bytecode, remaining fixed and immutable. In contrast, other upgradeable proxy contracts depend on trust of their administrator who is able to modify the implementation.
💡 Examples of ERC-1167 usage
1. OpenZeppelin: A widely-used library for secure smart contract development that provides an implementation of the EIP-1167 pattern called "TransparentUpgradeableProxy." It allows developers to create upgradeable contracts using a minimal proxy that separates the logic contract from the proxy contract. OpenZeppelin's implementation has been used in various projects across different sectors, including DeFi and NFTs.
2. Uniswap: The decentralised exchange uses a factory pattern to create exchanges for each ERC20 token. Uniswap has only one contract instance that contains the full exchange bytecode, while the rest are all proxies. Every proxy is a clone of that contract, but are initialised for different tokens. The main creation code for the Uniswap exchange implementation is 12468 bytes in length. However, a proxy contract is much more gas efficient - at 45 bytes.
3. How does ERC-1167 work?
A proxy is a contract that delegates its functionality to another contract, known as the implementation (or logic) contract.
Process of a typical minimal proxy:
Receives transaction data via a call and saves it
Forwards this data to the implementation/logic smart contract via
delegatecall
Retrieves the result of the external call from the implementation/logic contract
Returns the result if the external call was successful. If there was an error, it reverts the transaction instead.
Some important notes:
Minimal proxies delegate all calls to the implementation/reference contract, but keep the state in their own storage.
Multiple proxies can point to the same reference contract.
Proxies cannot be upgraded directly. However, you can indirectly do so by upgrading the reference implementation contract.
You cannot change the implementation contract of a proxy since its address is stored in the bytecode.
During execution, function calls will cost more gas due to the added
delegatecall
.
3.1 Implementation
This is the exact bytecode of a standard clone contract:
3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
Yup, that’s it. No Solidity involved. Okay, don’t get terrified - we’ll break it down.
Initialisation code:
3d602d80600a3d3981f3
This bit is responsible for deployment and stores the runtime bytecode on-chain.
To know more, read up on smart contract creation code.
Copy calldata:
363d3d373d3d3d363d73
When a transaction call is made to the minimal proxy, the proxy save the calldata passed to memory so that it can forward the said calldata to the implementation contract via
delegatecall
.This is how a function call and its parameters are passed along to the main contract to be executed.
Note that while the execution logic is stored on the implementation contract, with the use of
delegatecall
, the execution logic is “ported over” and executed within the context of the proxy. This means that the storage of the proxy gets modified, not the implementation contract.
Implementation address:
bebebebebebebebebebebebebebebebebebebebe
This is a placeholder address and should be replaced with the address of the actual implementation contract.
Delegatecall instruction:
5af43d82803e903d91602b57fd5bf3
Once the delegate call is executed, the minimal proxy will return the result of the call if it was successful. If an error occurred, it will revert the transaction.
3.2 Deploying minimal proxies with CREATE2
With that, let’s see how we can deploy minimal proxies with the create2
opcode:
/// @dev Deploys a new minimal contract via create2
/// @param implementation Address of Implementation contract
/// @param salt Random number of choice
function deploy(address implementation, uint256 salt) external returns (address) {
// cast address to bytes
bytes20 implementationBytes = bytes20(implementation);
// minimal proxy address
address proxy;
assembly {
// free memory pointer
let pointer := mload(0x40)
// mstore 32 bytes at the start of free memory
mstore(pointer, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
// overwrite the trailing 0s above with implementation contract's address in bytes
mstore(add(pointer, 0x14), implementationBytes)
// store 32 bytes to memory starting at "clone" + 40 bytes
mstore(add(pointer, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
// create a new contract, send 0 Ether
proxy := create2(0, pointer, 0x37, salt)
}
return proxy;
}
Since we need to manipulate the bytecode, the assembly that you see above is necessary. For the most part, the steps do not change and end-users can simply pass their implementation contract’s address as a parameter. Note that using create2 requires a salt to be passed. In this context, a salt is an arbitrary value (32 bytes) of the sender’s choice.
💡 Full code in Github repo, replete with testing:
https://github.com/arcane-group/Utils/tree/main
4. Concerns
As mentioned previously, a minimal proxy can reduce the storage fee of deploying multiple contracts. However, it will slightly raise the gas cost of operations in the future as a consequence of using delegatecall
. So if your contract does not contain many bytes and need to be called frequently, the use of ERC-1167 may not be as cost-effective.
There are also a few other drawbacks related to the use of ERC-1167.
4.1 Compatibility
A minimal proxy contract adds an extra layer of complexity to the contract architecture, which can make it more difficult to understand and debug. If the implementation contract is modified in a way that is not compatible with the proxy contract, it can cause the proxy to break. Therefore, it is important to carefully design and test the proxy contract to ensure its compatibility with the implementation contract.
It is also worth noting that while ERC-1167 provides a standard interface for minimal proxy contracts, it is not a required or universally adopted standard. Hence, it may not be compatible with all contracts on the Ethereum network.
4.2 Security vulnerability
Since the minimal proxy contract delegates functionality to the underlying main contract, more attention should be paid to the security of the underlying contract. It has to be secure, safe from the exploitation by malicious actors. Additionally, it is important to ensure that the implementation contract is properly tested and audited to reduce the risk of vulnerabilities.
4.3 Storage Layout
Another limitation is that while the storage of the proxy is used for execution, it is done in reference to the implementation contract. In the complex case where the implementation contract is upgraded, existing storage variables cannot be removed or modified. Instead, new storage variables must be appended.
Altering the storage structure in the implementation contract is problematic as the proxies would be expecting the old layout. Consequently, delegatecalls made would either utilise incorrect values by referencing the same storage locations on the new layout. This leads to a complete mess of the entire setup.
4.4 Constructors
Constructors are part of the creation code. They are only called and executed during the deployment of a contract, after which they are discarded. The result of deployment is that the runtime bytecode of the contract that is stored on-chain, but it does not include the constructor’s logic.
Therefore, proxies cannot imitate or access the construction of its implementation contract as there are no means to do so. This means that proxies cannot initialise themselves in the same way the implementation contract does so with its constructor. They need a different initialisation mechanism altogether.
The solution is simple: move the initialising logic into a separate function that is called on deployment. Ensure this function can only be called once, via a modifier.
💡 Avoid leaving a contract uninitialised. An uninitialised contract can be taken over by an attacker. This applies to both a proxy and its implementation contract, which means it could impact the proxy itself.
5. Code-based PoC
So you have spent all your brain cells studying minimal proxy contracts. How can you apply this knowledge practically? We can start with a PoC, or proof-of-concept. This is usually done by recreating a runnable code on a test environment.
First up, MinimalProxyFactory has the following interface:
function deploy(address implementation, uint256 salt) external returns (address) {}
function changeOwner(address proxy, address newOwner) external onlyOwner {}
function execute(address proxy, bytes calldata data) external onlyOwner returns (bytes memory) {}
function getAddress(address implementation, uint256 salt) public view returns (address) {}
function getByteCode(address implementation) internal pure returns (bytes memory) {}
Custom initialisation is made possible with modifications to the internal function _initialiseProxy
.
We have compiled a full code in a GitHub repo, complete with testing here. In our testing, we use SimpleRegistry.sol
. It contains two state-changing placeholder functions to illustrate the proxy functionality via testing suite.
Some key notes:
SimpleRegistry.sol
inheritsOwnableUpgradeable.sol
to allow use of theinitialiser
modifier. This ensures thatinitialise
is only called once.Our implementation simply makes
msg.sender
the owner.For your own implementation, be sure to overwrite
SimpleRegistry.sol
and extend the testing suite accordingly to your context.
6. Conclusion
In short, we have learned that the ERC-1167 helps smart contract developers to deploy multiple contract clones with a significantly lower gas cost. What’s more, this bytecode standard for minimal proxy is supported by Etherscan so you and others can verify the contract anytime.
Despite some limitations, the ERC-1167 is a useful design pattern for smart contract developers that makes operations more efficient when applied correctly.