Understanding how to architect large smart contract projects efficiently and securely is a key skill for smart contract developers. To do this effectively various “Smart Contract Patterns” are utilised.
This series “Smart Contract Patterns” will explore a number of these, diving into their origins, applications, and rationale. We’ll explore in-depth how traditional architecture & development principles translate into the world of smart contracts.
Each new pattern is a tool, one that can be used to aid you in building & maintaining decentralised applications.
Before we delve into these patterns, an extremely useful pre-requisite is a solid understanding of the EVM. Our previous series “EVM Deep Dives” explores the low-level implementation of the EVM and I would highly recommend reading that before starting.
Now let’s kick off the series with one of the most common patterns in smart contract development, “The Proxy”.
History
Understanding the history behind a given “Smart Contract Pattern” is extremely valuable. It sheds light on why it emerged, the specific problem it solved, and the design trade-offs made along the way.
Why Does This Pattern Exist?
For every pattern, we should start with a simple question.
“Why?”.
Why was this pattern created and what problem did it solve?
For “The Proxy” the why came from the immutability of smart contracts. Contracts are immutable by design, which prevents any updates to the business logic once the contract is deployed. This raised an obvious question.
How do we upgrade our smart contracts?
This question was initially solved with “contract migrations”. A new version of the contract would be deployed and all state and balances would be transferred to this new instance.
One obvious drawback of this was that the new deployment resulted in a new contract address. For applications integrated into wider ecosystems, it would require all third parties to also update their codebase to point at the new contract.
Another drawback was the operational complexity of transferring the state and balances to this new instance. Not only would this be very expensive from a gas perspective it would also be a very sensitive operation. Incorrectly updating the state of the new contract could break functionality and lead to security vulnerabilities.
The need for a simpler solution was apparent. How could we update the underlying logic of a contract without changing its address? How could we minimise the operational overhead?
From these questions “The Proxy Pattern” was formed.
The Original Proxy (The Delegate Proxy)
The original proxy, known as the delegate proxy used a few simple ideas to answer these questions.
First, we needed to separate the business logic and data storage into separate contracts. This was achieved with two contracts, “The Proxy Contract” for the data and “The Implementation Contract” (also known as the logic contract) for the business logic.
Next, we could utilise a delegate call from “The Proxy Contract” to access and use the business logic within “The Implementation Contract” in the context of “The Proxy’s” storage.
The fallback function within “The Proxy” would facilitate this delegate call. A fallback function is a function that executes when a non-existent function is called on the contract. This allows the contract to respond to arbitrary Ethereum transactions.
With this, we could access function signatures not defined in “The Proxy” and still use the “The Proxy” storage.
If you are unfamiliar with delegate call please refer to this article for an in-depth overview, a deep understanding of it is essential to understand the proxy pattern.
In short delegate call allowed us to use “The Implementation Contract” in the same way a Web2 app uses a library. This separation allows “The Proxy” to upgrade its business logic akin to updating a package version.
The image below shows these 2 contracts and a function call from a user to them. First with the v1 implementation and then with the v2 implementation.
The Delegate Proxy was a big step forward however it was not without its challenges
Problems With The Delegate Proxy
Several issues came from the new proxy architecture. Two key ones focused on collisions between the 2 contracts. Storage slot collisions and function signature collisions.
Storage Collisions
In Solidity, the storage layout is determined by the order of your variable declarations in your code. Alterations to this ordering during upgrades can lead to storage collisions - a critical issue where data is incorrectly read or overwritten.
If you’re unfamiliar with storage slots read this article for a deep dive.
Broadly these collisions could be broken into 2 types:
Storage collisions between “The Proxy” and “The Implementation”
Storage collisions between different versions of “The Implementation”
Proxy and Implementation Storage Collisions
The proxy pattern requires “The Proxy” and its “Implementation Contract” to share the same storage layout. If there's a mismatch it can lead to storage collisions.
A storage collision is where 2 different variables are assigned the same storage slot across 2 contracts. This can lead to a variable being incorrectly read or overwritten.
Say “The Proxy” has varA at storage slot 0 while “The Implementation” has varB at storage slot 0. You can see how this could cause issues when a delegate call is made from “The Proxy” to “The Implementation”.
This isn’t an unlikely scenario, “The Proxy” will likely have variables that “The Implementation” does not. For example, “The Proxy” needs to store the address of “The Implementation” contract at some storage slot.
It’s key that “The Implementation” contract doesn’t overwrite the implementation address slot, if it does it will have effectively broken “The Proxy”.
This was such a common problem that a standard, ERC-1967, was created.
This defined a specific storage slot (one guaranteed to be never allocated by a compiler) where “The Implementation” address should be stored.
Implementation Version Storage Collisions
When upgrading “The Implementation” contract, changes in the order or types of state variables can cause storage slots to be reassigned.
Take a look at the below example.
Original Contract Storage Layout:
address owner; // Slot 0
uint256 totalSupply; // Slot 1
mapping(address => uint256) balances; // Slot 2 (starting point of a dynamic mapping)
Upgraded Contract:
uint256 rewardMultiplier; // Now at Slot 0
address owner; // Moved to Slot 1
uint256 totalSupply; // Moved to Slot 2
mapping(address => uint256) balances; // Slot 3 (starting point of the dynamic mapping has shifted)
It’s easy to see how this slot reassignment could cause havoc for this contract and open many security vulnerabilities. One obvious example is the moving of the owner variable to a new slot.
If the contract logic attempts to access “owner” using its original slot 0, it would mistakenly interact with “rewardMultiplier” instead.
Care needs to be taken when upgrading “The Implementation” to ensure the storage layout isn’t compromised.
Function Collisions
Another major issue was function signature collisions.
To understand function signature collisions first you must understand how the EVM interprets function calls to a solidity contract.
An in-depth overview of this can be found here if you need a refresher.
In summary, function selection within a contract is determined by the 4-byte function signature. These are derived from the name of the function and its input types.
Collisions of these signatures can occur, leading to ambiguity in function calls and security vulnerabilities. Let’s highlight one of these vulnerabilities.
Proxy Contract:
function collate_propagate_storage(bytes16) external {
implementation.delegatecall(abi.encodeWithSignature(
"transfer(address,uint256)", proxyOwner, 1000
));
}
Implementation Contract:
function burn(uint256 value) public virtual {
_burn(_msgSender(), value);
}
function transfer(address to, uint256 value) public virtual returns (bool) {
address owner = _msgSender();
_transfer(owner, to, value);
return true;
}
The above highlights certain functions in “The Proxy” and “The Implementation” contracts.
The setup for this vulnerability is that an untrustworthy proxy is pointing to a trusted implementation contract.
This untrustworthy proxy has implemented a new function collate_propagate_storage(bytes16) in “The Proxy” contract.
A user comes along to interact with “The Proxy”, let’s say they heard there was an associated airdrop if they used it. They focus on checking “The Implementation” to verify it isn’t doing anything malicious, this is where all business logic is located.
“The Implementation” uses a standard OpenZepplin contract which is trusted and has been thoroughly tested. They notice collate_propagate_storage(bytes16) in “The Proxy” but think nothing of it. It is not the function or code they will be interacting with.
The user now satisfied calls burn(1) on “The Proxy” to burn one of their tokens. When the transaction lands on-chain they see that instead of burning 1 token they have transferred 1000 tokens to another unknown account.
What just happened?
Let’s look at the function signatures of burn and collate_propagate_storage.
burn(uint256) = 0x42966c68
collate_propagate_storage(bytes16) = 0x42966c68
You can check these out for yourself here, paste the function name and input types above into the keccak256 simulator and see the resulting hash.
Note that the full hash is different but it doesn’t matter since we only need the first 4 bytes to match.
When the user called burn(uint256) the following occurred:
The EVM didn’t see a call to burn(uint256) it saw a function call to 0x42966c68.
Since this function signature existed in “The Proxy” as collate_propagate_storage(bytes16) the call wasn’t passed to the fallback function.
Instead, it was passed to collate_propagate_storage(bytes16). This in turn called a transfer of 1000 tokens to the proxyOwner.
(See here for a deeper breakdown by tincho).
While the example above highlights an exploit the same is true when “The Proxy” and “The Implementation” have functions with identical signatures for non-malicous valid reasons.
Say both contracts have an updateSettings() function. When a user tries to invoke this function how does the contract know which function you are intending to call, “The Proxy” or “The Implementation”?
This ambiguity can lead to unintended errors or even malicious exploits.
It was such a serious problem that a new proxy was created to solve this exact issue, the transparent proxy.
The Transparent Proxy
The core idea of the transparent proxy was to have 2 different execution paths for admin and non-admin users.
If the admin was calling the contract “The Proxy” functions would be available. For anyone else, all calls would be delegated to “The Implementation” via the fallback even if there was a matching function signature.
This removed the ambiguity, admins could interact with “The Proxy” functions and non-admins could only interact with the “The Implementation” functions.
One drawback of this setup was that normal users would no longer be able to access the read methods on “The Proxy”. For example, accessing the getter for “The Implementation” address.
Instead, they would have to use web3.eth.getStorageAt( ) and the problem with getStorageAt( ) is that you need to know where in the storage to look.
ERC-1967
Before ERC-1967, the standard we mentioned earlier, various proxies would implement different storage locations for “The Implementation” address.
This meant third-party apps like Etherscan couldn’t identify which slot to check to get information about “The Implementation”.
ERC-1967 solved this by providing a pre-defined storage slot for “The Implementation” address.
If we look at Etherscan they show the code for both “The Proxy” and “The Implementation” on their explorer. This is only possible if we have a known storage slot for obtaining “The Implementation” address.
The ERC-1967 also provided defined slots for the “beacon address” (we’ll touch on this later) and the “admin address” as well as ensuring events were emitted when any were changed.
Code Deep Dive
Let’s now take a look through the OpenZepplin implementation of the transparent proxy and the 2 execution paths (admin & non-admin) to get a better understanding of what’s going on.
We’ll start with the user (non-admin) execution path.
User Access (Non-Admin)
Let’s start with the inheritance structure of the transparent proxy, we have 3 contracts one of which is abstract. An abstract contract is akin to an abstract class in that it cannot be instantiated on its own because it contains at least one function without a concrete implementation (this must be defined by the developer). Our core proxy contract “TransparentUpgradeableProxy” inherits from “ERC1967Proxy” which inherits from the “Abstract Proxy”. These represent the 3 contracts you see above.
Next, let’s briefly touch on the constructor. Constructors are executed in the order of inheritance, starting from the base class to the derived class. This means first the “ERC1967Proxy” constructor will be called then “TransparentUpgradeableProxy”. The “Abstract Proxy” contract has no constructor. Inherited contract constructors are automatically called however we must explicitly call them if they take in parameters such as “ERC1967Proxy” does. This is why we have ERC1967Proxy(_logic, _data) in the “TransparentUpgradeableProxy” constructor. This is the syntax for explicitly calling the constructor with specific inputs.
Now let’s start with the actual function call. A non-admin user will make a call to “TransparentUpgradeableProxy”. Note that every function in all 3 contracts above is private (denoted by _ prefix), as a result, any call is going to be passed to the fallback( ) function. Note the difference between _fallback( ) which is private and fallback( ) which is the actual fallback function. There is no fallback( ) in “TransparentUpgradeableProxy” but there is in the inherited “Abstract Proxy” contract. That will be our entry point.
The fallback( ) function just passes us along to the internal _fallback( ) function. A _fallback( ) function exists in both the “Abstract Proxy” contract and the “TransparentUpgradeableProxy” contract. Since “TransparentUpgradeableProxy” is the derived contract its _fallback( ) overrides “Abstract Proxy” and is therefore where the call goes.
At the _fallback( ) there are some checks to see if the user is the admin user, since we are not the admin we will be passed to super._fallback( ). Super is a keyword to call a function in the parent class, in our case “ERC1967Proxy”.
Since “ERC1967Proxy” doesn’t contain a _fallback( ) function we need to go up one level to the _fallback( ) in “Abstract Proxy”. This _fallback( ) in turn calls _delegate(_implementation( )) where _implementation( ) returns the address of the implementation contract.
The _delegate( ) implementation utilises some inline assembly to make the delegate call. (See evm.codes for a breakdown of each OPCODE)
Line 27: calldatacopy(destOffset = 0, srcOffset = 0, length = calldatasize())
This copies the calldata, at offset 0 of length calldatasize() into memory at offset 0 to be used in the delegate call.
Line 31: delegatecall(g = gas(), a = implementation, in = 0, insize = calldatasize(), out = 0, outsize = 0)
g = gas - The amount of gas to be sent along with the call. This must be enough for execution.
a = address - The address of the contract for the delegate call, in our case the implementation contract.
in = start memory location input - This marks the start position of the input data in memory that will be sent to the target contract, remember that calldatacopy copied to memory position 0.
insize = input size - The size (in bytes) of the input data, in our case the calldatasize( ) since we are passing everything along.
out = start memory location output - Marks the start position in memory where the output data of the delegate call will be stored, position 0 is selected.
outsize = output size - The size (in bytes) of the output area in memory, in our case this is 0 which means nothing will be stored in memory.
Note the output value (not the result) of the delegate call will be stored in the return data buffer. This can be accessed using returnDataCopy. This means the return value is still available even though we didn’t save it to memory.
The variable “result” captures whether the delegate call was executed successfully or not. 0 indicates that the execution was unsuccessful.
Line 34: returndatacopy(destOffset = 0, srcOffset = 0, length = returndatasize())
This copies the contents of the return data buffer at offset 0 of length returndatasize() (which contains the output of our delegate call) into memory at offset 0.
You may have noticed that returndatacopy() just copies the return data to memory and asked why didn’t we do that in the delegate call with “out” and “outsize”. The issue is that we didn’t know the size of the return data at that point. If we had we could have copied the return data to memory immediately via the delegate call removing the need for returndatacopy().
Line 36: A switch statement, in both cases the output is returned to the user either via a return or a revert depending on whether the delegate call was successful or not.
return(offset = 0, size = returndatasize()) - Returns from memory starting at the offset 0 the specified size returndatasize() which is the output of the delegate call.
revert(offset = 0, size = returndatasize()) - Same as return but state changes are reverted and unused gas is returned to the caller.
With that we have covered the User (Non-Admin) flow, now let’s take a quick look at the Admin flow for the Transparent Proxy.
Admin Access
The Admin flow introduces a new contract “The Proxy Admin” and the library ERC1967Utils. Below you will see how they are utilised.
To understand the admin flow we first need to see who the _admin is in our core proxy contract “TransparentUpgradeableProxy”. We can see admin is set in the constructor. The constructor initialises a “ProxyAdmin” contract and sets the _admin as the ProxyAdmin contract address. This means it is the “ProxyAdmin” contract, not the ProxyAdmin owner EOA that is authorised.
Because of this, our call from our admin user must go through the “ProxyAdmin” contract not directly to “TransparentUpgradeableProxy”. We must be the owner of the “ProxyAdmin” contract.
We call upgradeAndCall passing in the proxy we want to target, the new implementation address and the data for an (optional) call on that new implementation. This in turn calls upgradeToAndCall on the proxy.
As explained before all calls will end at the fallback since all other methods are private. The fallback( ) then calls the private _fallback( ) method.
We have the admin check again using msg.sender but this time we are the _proxyAdmin( ). Note _proxyAdmin is just a getter on _admin. This a good time to remind you of the difference between tx.origin and msg.sender. tx.origin refers to the original external account (EOA) that initiated the transaction, in this case, the owner of the ProxyAdmin contract. msg.sender is the immediate caller of this contract which in this case is the ProxyAdmin contract. We pass the admin check, then verify only the specific method “upgradeToAndCall” is being called. if it isn’t revert the call, if it is, call the function _dispatchUpgradeToAndCall( ).
_dispatchUpgradeToAndCall( ) grabs the new implementation address from the calldata and then uses the ERC1967Utils.UpgradeToAndCall, passing in the new implementation address and any data for a subsequent call.
ERC1967Utils.UpgradeToAndCall verifies the code at the new implementation address is non-zero and then updates the storage space specified in ERC-1967 with the new implementation address.
If the data length is > 0 it means the user wanted to make some call after the update so make that delegate call at the new address. If data length is 0 verify the call has no value, this is just to prevent funds getting stuck in the contract.
And that’s the Admin flow.
Concepts have become code, you have now seen how the theory is implemented within solidity. This will help us deepen our understanding of how proxies work and potential security vulnerabilities to watch out for.
However the transparent proxy was not the last iteration of the proxy pattern, there is one more that we need to review. The reasons for why this proxy was created are primarily focused on the gas usage of the transparent proxy.
The introduction of the admin check within “The Proxy” meant that the admin needed to be loaded from storage on every call. Solidity devs will know loading from storage is one of the most expensive OPCODES in the EVM.
This gas overhead for users (and therefore increased costs) led to the development of the UUPS Proxy (Universally Upgradable Proxy Standard).
UUPS Proxy
The key differentiator with the UUPS proxy was moving the “upgradeToAndCall” logic from “The Proxy” contract to “The Implementation” contract.
This change meant “The Proxy” became a simple forwarder of all calls to “The Implementation” contract via delegate call.
With the auth now in “The Implementation”, we no longer needed the ProxyAdmin contract and we reduced the gas overhead of checking msg.sender against the “admin” for every call to “The Proxy”.
Instead, the authorisation logic and subsequently the gas-expensive SLOAD of the admin address is only executed when upgradeToAndCall is called. As a result, all non-admin user calls avoid this SLOAD.
OpenZepplin provides an abstract UUPS contract that can be used as a base for your “Implementation”. It leaves one function _authorizeUpgrade(address newImplementation) undefined so that you, the developer, can implement your own custom authorisation. Here features such as time lock upgrades, multi-sig upgrades, etc. can be implemented.
When coding your “Implementation” contract one important item to be aware of is initialiser functions. You should know why they exist and their differences from constructors.
Constructors vs Initialisers
Constructors are used for initialising state in contracts. They are executed once at contract deployment and their code isn’t included in the contract bytecode.
In the proxy pattern, “The Proxy” (holding the state) and “The Implementation” (holding the logic) are separate. Because of this any state initialisation done in the constructor of “The Implementation” affects only “The Implementation” contract's storage, not “The Proxy's”.
To address the limitation of constructors in proxy setups, initialiser functions are used. These are designed to set up the initial state in “The Proxy's” storage.
Initializers are designed to be executed via a delegatecall from “The Proxy”. This ensures that the state initialised is within “The Proxy's” storage, aligning with the intended logic of “The Implementation” contract.
Similar to constructors, initialisers are meant to be executed only once. This is typically enforced through a mechanism, like a boolean flag, to prevent re-initialization, which could lead to security vulnerabilities.
One thing to be aware of is that, unlike constructors, initializers do not automatically handle inheritance. Under the hood, an initialiser is just a normal function meaning if any of “The Implementation’s” parent contracts have constructors, they need to be explicitly called in the initializer. This differs from a constructor where parent constructors are automatically called.
When implementing upgradable contracts, extra care must be taken with initialiser functions. Ensuring they are secure and only callable as intended is crucial to maintaining the integrity of the contract.
Now back from that detour to the advantages and disadvantages of the UUPS Proxy.
Advantages & Disadvantages
There are both pros and cons to the UUPS.
The major advantages are as follows:
Reduction in gas overhead for your users.
Removes the need for the ProxyAdmin contract.
The upgradeAndCall logic itself becomes upgradable since it’s in the implementation and the implementation can be upgraded. This includes the ability to eventually remove it and enshrine a contract in its current state.
The disadvantages include:
Reduced separation of concerns, your implementation contract now handles both your auth logic and your business logic.
Increase risk when updating the implementation contract. Since your implementation now contains your auth logic every upgrade potentially changes the attack surface of your auth logic.
Risk of “bricking” the proxy. If there is inadvertently an upgrade to the implementation contract that doesn’t contain the upgradeAndCall function, the upgrade functionality for the proxy is lost forever.
If you want to dive into the UUPS abstract implementation contract you can here.
Our final topics for today are to briefly touch on the minimal proxy (also known as clones) and the beacon proxy. Two proxy concepts you may see out in the wild.
The Minimal Proxy
The concept of a minimal proxy is to offer a streamlined approach to deploying multiple instances of a contract that share common logic but require individual storage.
An example of this could be Gnosis Safe contracts, where each Safe is distinct but the underlying multisig logic remains consistent.
Rather than redeploying the entire logic for each new instance, which is gas-intensive and costly, the minimal proxy pattern involves deploying a single implementation contract and then creating lightweight proxy contracts for each new instance.
Minimal proxies do not include upgradeability or authorization functionality, simplifying their structure and reducing both deployment and runtime gas costs. They are static and immutable once deployed.
The Beacon Proxy
The Beacon Proxy pattern introduces an efficient upgrade mechanism for systems where multiple proxy contracts require synchronized updates.
The design utilises a separate contract known as “The Beacon”, which holds the implementation address used by all associated proxies. Each “Proxy” then simply queries “The Beacon” to retrieve the current implementation address rather than holding it themselves.
The Beacon Proxy is very useful when an update across numerous proxy instances is required. Going back to our Gnosis Safe example, where each user's Safe is a proxy contract, imagine a critical update was needed.
Updating the implementation address of each Safe (Proxy) individually would be prohibitively expensive in terms of gas and require huge coordination efforts with users (the deployers and owners of their Proxies) to update.
With a Beacon Proxy, the maintainers of the platform, such as the Gnosis team, need only to update the implementation address in “The Beacon” contract.
All proxy instances pointed at “The Beacon” will be updated as well.
This not only saves gas (as the implementation address only needs to be updated in one location) but also significantly reduces the operational burden since users don’t need to perform individual upgrades.
The one drawback is of course that the pattern does centralise control over the implementation address. The owner of the beacon represents a significant point of trust.
To mitigate this trust and enhance security, mechanisms such as multi-signature wallets and time locks can be implemented.
We’ve covered a lot in this article, traversing the intricate landscape of Proxy contracts. Hopefully, you’ve learned new concepts and added one more pattern to your toolkit.
Till next time.
noxx
Twitter @noxx3xxon
The best proxy article I ever read
This is best rehash for all important pointers or mindmaps for proxy
Noxx returns the home. This is perfect