Smart contract development presents a unique challenge: once deployed to the
blockchain, your code becomes immutable. This immutability, while providing
security and trust guarantees, creates significant difficulties when you need
to upgrade your contract logic or fix critical vulnerabilities. Enter the
Transparent Upgradeable Proxy Pattern - an elegant solution that allows
smart contracts to be upgraded while maintaining their state and address.
The Immutability Challenge
Why Smart Contracts Can't Be Changed
When you deploy a smart contract to Ethereum or any EVM-compatible blockchain,
the bytecode is permanently stored on the blockchain. This immutability
provides several benefits:
- Trust: Users can verify the contract code and know it won't change unexpectedly
- Security: No single party can maliciously modify the contract logic
- Decentralization: The contract operates independently without central control
However, this immutability creates significant challenges:
Difficulties in Upgrading Smart Contract Logic
- Bug Fixes: If you discover a critical bug in your contract, you can't simply patch it like traditional software
- Feature Updates: Adding new functionality requires deploying a completely new contract
- State Migration: Moving user data from old contracts to new ones is complex and expensive
- Address Changes: A new deployment means a new contract address, breaking integrations and user bookmarks
The Vulnerability Problem
Consider this scenario: You've deployed a popular DeFi protocol with millions
of dollars locked in it. Suddenly, a security researcher discovers a critical
vulnerability that could drain all funds. In traditional software, you'd push a
hotfix immediately. But with immutable smart contracts, you're stuck with three
bad options:
- Deploy a new contract - Users must migrate manually, often with significant friction
- Live with the vulnerability - Hope nobody exploits it while you work on a migration
- Use a kill switch - Pause the contract, but this defeats the purpose of decentralization
Enter the Transparent Upgradeable Proxy Pattern
The proxy pattern solves these problems by separating storage from
logic. Here's how it works:
- Proxy Contract: Holds all the state/storage and delegates function calls to the implementation
- Implementation Contract: Contains the business logic but no state
- Admin: Can upgrade the implementation while preserving the proxy's state and address
Key Components
-
Delegatecall: The proxy uses
delegatecallto execute implementation code in proxy's context - Storage Slots: Carefully managed to avoid collisions between proxy and implementation
- Transparency: Admin calls access proxy functions, user calls are forwarded to implementation
Building a Practical Implementation with Foundry
Let's build a real-world transparent upgradeable proxy using Test-Driven
Development (TDD) with Foundry. We'll use a practical example from an NFT
contract system.
To get a full grasp on the implementation, go to Nifty
repository.
All code examples here exist in this repository and you could test it and even
deploy it on a test network if you want to play a bit.
A first look
When I start a project, one of the first thing I do is executing tests to see
if everything is in order with a fresh clone on a new environment.
With Nifty, it's pretty simple:
- Go to the
contractssubdirectory and run:make test
Everything should be green.
If not, please, report in comments and I'll do my best to get it in the right
color for you.
Now let's focus on the contracts/test/proxy/TransparentUpgradeableProxy.t.sol
file.
Feel free to browse all tests in this suite but for now, let's focus on those
that are designed to ensure the tricky part of the proxy are well implemented.
Deep diving in most important cases
test_constructor_throws_withFailingInitializableImplementation
This test ensures a TransparentUpgradeableProxy contract cannot be deployed if
the underlying implementation fails to initialize with an existing initialize
function.
This is very important because proxy deployments cannot use constructors to
initialize their state. Thus, they rely on an initialization function that
MUST be in the implementation contract.
In this test the implementation contract is
FailingInitializableImplementation located in the contracts/test/Mocks.sol.
The initialize function just revert. As simple as that.
test_constructor_initializes_triviallyConstructibleContract
Should the underlying implementation be simple enough to not have specific
initialization logic, this test ensure a proxy can handle it. No call to an
initialize function is needed.
test_admin_returnsProxyAdmin_ifAdmin
The TransparentUpgradeableProxy pattern imposes specific behaviors regarding
which function to call depending on who's calling:
- if the sender is the proxy admin, there is no routing to underlying implementation and proxy functions are directly called
- If the sender is someone else, a forward is done using
delegatecall. Proxy functions will never be called. Should an in-existing function called, the transaction is reverted.
test_admin_returnsImplementationAdmin_ifNotAdmin
The reciprocal of above, in this case, the test will call the admin function
of the implementation contract TestImplementation located in
contracts/test/Mocks.sol
test_stateChangesOccurInProxyStorage_forNotAdminCalls
This one ensures all state changes occur in the proxy slots instead of the
implementation contract slots. This test also proves there is no collision
between proxy storage slots and implementation contract storage slots.
A real wold application: Nifty
This ERC721 token implementation fully use the transparent upgradeable proxy
pattern at its core. To get further, all features of this token (and also the
associated Crowdsale contract) are tested both:
- directly (using contract functions)
- through a transparent upgradeable proxy
Take a look in the contracts/test/Nifty.t.sol for a very concise overview on
how it works.
A powerful Foundry feature is leveraged here: table testing, allowing to
parameterize test.
In a nutshell, each test is executed twice:
- using the
Niftycontract directly - by calling
Niftycontract functions through a proxy
Conclusion
There would be so much to say about this fantastic pattern but I think I
covered the most important aspects here.
I hope this article could be seen as both a concise explanation on what
transparent upgradeable proxies are in solidity and how to use them practically
in a real world project using proven development methodologies.
I'd love some feedbacks of the community to improve both this article and the
Nifty implementation.
Top comments (0)