Intro
In a previous article I made a presentation on Transparent Upgradeable Proxies in Solidity; what they are, why they are useful and key concepts about how they work.
I let readers to dive in at their leisure in the code in this repository should they wish to know more.
I had some feedback though telling me it could be valuable to expose this proxy implementation code alongside precise explanations about how it works.
So here we are, 100% proxy, 100% focus.
Context
Let's keep something that is working by taking some code from The Nifty repository :
Study of TransparentUpgradeableProxy.sol
This is the actual transparent proxy implementation.
In this section I'll make a parallel between concepts I exposed in this article and the actual code.
Storage Slots
I've talked about storage slot management, in particular, storage slots collision.
Remember, a proxy call effectively calls an underlying implementation contract function but all state management is done within the proxy. This is the basis of how delegatecall works.
You see, this proxy implementation has also some states to manage:
- the address of the current
administratorof the proxy - the address of the current
implementationcontract to call functions of.
If those states were declared as they are in the implementation contract (or any normal contract), they would collide together and things would get very hot, very soon.
But wait... there are no state member within this proxy contract.
The only thing you have are:
- weird constants whose values are provided (for gas optimizations)
- a modifier and functions using those weird constants through a weird type named
ProxyStorage
Weird constants
You can see they are result of hashing a string. In fact, the result of this hashing will give us the slot number where to store state of this proxy.
Therefore, ADMIN_SLOT will be the slot where we'll store the address of the proxy admin and IMPLEMENTATION_SLOT, the slot where we'll store the current underlying implenetation contract.
The library
Let's take a look at the ProxyStorage.sol file.
It defines a solidity library. A library can be seen as a base contract whose exposed function are delegatecalled somehow, allowing those called function to modify the state of the caller contract (libraries in solidity).
This library only expose one function which gives us a reference on a stored struct object.
Keep in mind the stored struct object lies in the caller contracts in our case: TransparentUpgradeableProxy.
This function is quite low level as it uses inlined assembly directive.
This is to override the default compiler behavior.
By default, the compiler store states of the contract at the first slot available starting at 0 and going forward (at leat for value types).
With this function, we can choose the slot (the location) of the TransparentUpgrageableProxy state variables thus, avoiding collisions with underlying implementation contract whose layout is deduced by the compiler default behavior I told just before.
assembly ("memory-safe") { // memory-safe indeed, we just change a slot number
r.slot := slot // define the slot number of the returned struct object
}
Therefore, in the transparent proxy implementation a function such as
function getAdmin_() private view returns (address) {
// returns the value (address) stored in the ADMIN_SLOT slot number
return ProxyStorage.getAddressSlot(ADMIN_SLOT).value;
}
ensures we do not collide with any underlying implementation contract state.
Call delegation
This key concept of transparent proxy deserves a bit of explanation.
To put simply, the proxy effectively use of delegatecall but it is fine tuned for several reasons. Let's explore them:
- Gas efficiency
- Any, I say any non-admin function calls to an underlying implementation contract pass through the proxy. Gas saving is paramount. This is why
assemblyconstruct is used here as we'll see
- Any, I say any non-admin function calls to an underlying implementation contract pass through the proxy. Gas saving is paramount. This is why
- call delegation through
fallbackfunction- The fallback function is a particular beast. It does not return any value. But proxy delegated calls must work with functions returning values. Once more,
assemblyto the rescue!
- The fallback function is a particular beast. It does not return any value. But proxy delegated calls must work with functions returning values. Once more,
- standardization
- In fact, this implementation of delegated call is the same as the one in the OpenZeppelin repository. Understand that it's a super standard way to implement transparent proxy. I did not invent anything new here.
Here's the code:
function fallback_(address implementationContract) private {
// we'll see why in the next section about transparency
require(msg.sender != getAdmin_(), InvalidAdminCall());
assembly {
// Copy msg.data.
// gas saving (no bytes allocation)
// no harm due to scratch pad over-writing because there is no solidity code after this inline assembly block
calldatacopy(0x00, 0x00, calldatasize())
// this is the actual delegatecall
// Call the implementation.
// offset 0 and size unknown
let result := delegatecall(gas(), implementationContract, 0x00, calldatasize(), 0x00, 0x00)
// copy return data starting from offset 0 using return data size we know at this point
returndatacopy(0x00, 0x00, returndatasize())
// either returns or revert, using return data size
switch result
case 0 { revert(0x00, returndatasize()) }
default { return(0x00, returndatasize()) }
}
}
Transparency
In this project, the proxy must behave differently depending who's using it. Put simply:
- If the sender is the proxy
admin, call proxy function without forwarding to the underlying implementation. Calling an unexisting function leads to revert. - If the sender is not the proxy
admin, delegate function call to the underlying implementation even if this function exists in the proxy.
In the code, this is represented by the onlyAdmin modifier used in proxy getter functions (admin and implementation):
modifier onlyAdmin() {
if (getAdmin_() == msg.sender) {
_; // call of the proxy function
} else {
// delegatecall to underlying implementation
fallback_(ProxyStorage.getAddressSlot(IMPLEMENTATION_SLOT).value);
}
}
Some experts reader might say writing modifier like this is an anti-pattern. I understand. Specify if-else construct might obscure the intent of such modifier this is true. But in my case, I find it acceptable and it removes duplication; proxy are truly very specific and this code is not intended to change a lot.
Conclusion
I hope this deep tech dive into transparent proxy code is clear.
I'd like to remind you though this implementation is functional, it is largely imperfect and can be enhanced a lot. Always prefer stardardized solution such as Open Zeppelin for your proxy needs.
Thanks
- @christopherkade for it's banner generator
- Open Zeppelin hard work that inspired me
- Ethereum ecosystem and community for the hard work they have been providing.
Top comments (0)