Smart Contract Upgradeability
Updating apps on an immutable blockchain, like switching the plane engine midflight
Here’s the definition of high-stakes programming: the popular NFT project AkuDreams accidentally locked the entirety of a $34 million fundraise into a smart contract forever. I wrote a popular tweet thread detailing the vulnerability here:
This particular writeup escaped the crypto bubble into mainstream media, creating a smorgasbord of hilariously misunderstanding responses.
Some wondered, why not just fix the code? Bugs are an inevitable part of software development; build pipelines and version upgrades are a key part of every tech stack.
The answer is deceptively simple - smart contract bytecode is immutable once deployed. But that hides a complex system that developers have created to write upgradeable apps on top of an immutable baselayer. Let’s dive deeper.
How smart contracts operate
Here we’re discussing Solidity smart contracts running on the Ethereum Virtual Machine (EVM), the programming environment used by chains like Ethereum, Binance Smart Chain, Avalanche, Polygon, Fantom, and more.
An interesting quirk of the EVM is that user accounts and smart contracts occupy the same 40-hexadecimal-character address space. The difference is that externally owned accounts (EOAs) are operated by private keys, while smart contracts cannot send transactions by themselves. Contracts’ deployed bytecode provides functions that EOAs can call, but a contract can’t trigger itself.
The “cronjob” equivalent on a blockchain is called a keeper job, where you pay a bot network run by Chainlink/Keep3r/others to periodically call upkeep functions such as liquidations, rebalancing, or autoharvesting on your smart contracts.
A smart contract is executable bytecode stored on the blockchain. Once code is deployed to a smart contract address, the code cannot ever be changed (aside from selfdestruct which is a blanket deletion). So if code can’t be changed, how do we handle upgradeability?
I’ll explore three key approaches used here, from “most immutable” to “most flexible”:
1. Storage Parameters
Each contract has its own storage scope that only it can touch. The most common primitive types are integers, addresses, mappings, and arrays. These storage variables are not immutable, and function logic can change them at any time.
So the simplest form of upgradability is using a governance-locked method to update certain economic parameters. If you’re running a StakingPools contract, you can update the reward rate that tokens get dripped out to stakers. If you’re running a LendingPools contract, you can update the deposit or borrow interest rates. This doesn’t change bytecode because it just updates the value held in a storage slot.
2. Contract Pointers
Sometimes you might want to overhaul the logic of a contract, not just a parameter. So you might have a main dispatch contract that holds the addresses of the actual contract and makes the call there.
A good example is the Aave V3 codebase. The main entrypoint calls a known address provider contract, that provides address pointers to the system’s moving pieces. Governance can deploy new contracts with fresh bytecode at new addresses, then update the address provider’s storage variable from $OLD_ADDRESS to $NEW_ADDRESS.
3. Proxies
Smart contracts have immutable bytecode and mutable storage context. Typically these are meshed seamlessly together, whether the bytecode either makes external calls or modifies its own storage context.
But the EVM has a special opcode called delegatecall, which fetches the bytecode from a different, external contract and executes that bytecode within the original contract’s storage context. This is somewhat equivalent to downloading a script from the Internet and running it on your home computer, so this should be used with extreme caution only on known trusted external contracts.
Let’s take an ERC20 token as an example. The bytecode is a couple functions that specify who is eligible to mint, whether there are transfer taxes, whether it’s pausable, etc. The storage context contains data such as a mapping of holder addresses to holder balances. Using delegatecall means that the ERC20 is responsible for keeping its storage context/holder balances, but it outsources function logic/minting rules to an external smart contract. And you can switch out which smart contract you delegate your function logic to.
This is useful because it lets you patch logic errors without losing any important storage context. It’s also quite expressive. Governance can deploy any logic they want, including malicious actions such as an infinite mint or burning a specific holder’s tokens or accidentally deploying a broken upgrade. With great power comes great responsibility.
Check out the OpenZeppelin docs for a detailed walkthrough of how to deploy your own proxy and logic contracts.
Advanced: Development Nuances
This is a bonus technical section. Feel free to skip if you’re not an active Solidity developer.
The contract containing the storage context is known as the “proxy contract”, and the contract containing the bytecode implementation is known as the “logic contract”. Third-party users will make calls to the proxy contract, which will then fetch the logic contract’s bytecode under the hood and execute it within the proxy’s storage context.
There are some Solidity nuances here to get right if writing upgradeable logic contracts:
All logic contracts must have empty constructors because the constructor is executed within the logic contract’s storage context atomically upon deployment. Setups that would normally go within the constructor should be moved to an initializer function called separately on the proxy’s storage context after deployment.
The EVM lays out storage in a very particular way, so care must be taken to not reorganize the order of variable declarations. Best practice is to always make storage declarations in the same order, and if new storage is needed then that should be placed after all previous declarations.
There are also function clashes to worry about - the proxy contract has several minimal functions used to upgrade its logic contract pointer, such as owner() and upgradeTo(). Obviously only the proxy owner should be able to upgrade the logic contract. But what happens if the logic contract also has an owner() method, which should take precedent?
Current best practice is to produce different behavior based on which address is calling. If the calling address is the proxy owner, then it will never delegatecall to the logic contract. If the calling contract is not the proxy owner, it will always delegatecall.
This creates yet another edge case failure where the proxy owner may wish to interact with the logic, so to fix this an intermediary ProxyAdmin contract is deployed that all calls to the proxy are routed through. This enables the proxy owner to route their calls through the ProxyAdmin to upgrade the logic contract, but also to route their calls directly through the proxy contract to interact with the logic.
Extremely Advanced: Proxy Types
While the general idea of storing delegatecalling external bytecode is the same across all proxy types, several different implementations have been proposed. I’ll lightly detail them here.
The EIP-1967 standard specifies a specific storage slot, 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, guaranteed to never be allocated by the compiler. This helps block explorers like Etherscan decode and point to logic contract methods when people view the proxy contract.
A full walkthrough of how to deploy a UUPS or Transparent Proxy via OpenZeppelin can be found here.
Universal Upgradeable Proxy (UUPS)
A version where the upgrade logic is stored in the implementation contract. This means it can be removed at a later date, or accidentally frozen if you forget to include the upgrade functions. However, by virtue of more focused code it slims down both the deployment and interaction costs compared to a transparent proxy.
Transparent Proxy (Writeup)
A version where the upgrade logic is placed in the proxy itself. This makes deployment more expensive but less chance of shooting yourself in the foot on logic contract upgrades.
Beacon Proxy (Details)
A version letting you upgrade multiple proxies to a new implementation address in a single call. Useful for when you have clones or copies of a given storage context.
Wrapping it up
You can build mutability on top of an immutable baselayer, but you cannot do the opposite. USDC can be built onto Ethereum, but Ethereum can’t be built on SWIFT. It’s a wholly different paradigm from hosting your own server-side code that can be changed on a whim, but a much stronger foundation on which to build financial primitives.
Hope this info helps you evaluate the safety & security of various protocols you interact with!
Thanks. I have a strong background in backend development, mostly with Python and this has helped my understanding the problems of upgradeability in blockchain context. Totally different trade-offs and things to consider compare to mutable environment
For 1 - on what platform do you store it? it seems the storage won't be in memory, otherwise it would become part of the bytecode and you cannot modify it.