Upgradeable smart contracts are bringing flexibility to the otherwise immutable code. Many companies, such as Compound Finance and OpenSea widely adopt them as the future of smart contract development. However, security problems are also emerging. Critics say upgradeability weakens blockchain’s immutability, while supporters believe it enhances safety by addressing bugs and preventing exploits. Let’s break it down.
Typically, smart contracts are unchangeable once deployed. This immutability builds trust among DeFi parties since even the contract’s creator can’t alter it. However, this also means they can’t be updated, posing risks if security or other issues arise.
Upgradeable Smart Contracts (USC) solve these issues by allowing updates without the need to migrate all activity to a new address.
The earliest and most intuitive method of updating smart contracts with no state migrations is the data-separation pattern. Here, we separate a smart contract into logic and storage contracts, storing and changing the state respectively. The problem with this method is evident: constant calls between logic and storage require gas.
That’s why modern USCs rely on proxies. A fixed proxy contract stores the system’s state and a changeable logic address. Unlike normal contracts, users communicate with the proxy, which forwards their calls to the business logic contract. In proxy-based upgradeability, logic contracts don’t store any user data. Hence, the upgrade is easy: a new logic contract is deployed, and the only call needed is to replace the old address in the proxy contract.
Transparent proxies handle access control and upgradability within the proxy. Both admins and users are limited to logic within their access range.
Pros | Cons |
Simple and easy to understand. | More expensive in terms of gas cost due to storage layout requirements. |
Most tools and libraries support it. | Contains specific storage slots that are reserved, which can lead to confusion. |
Transparent Proxy Pattern is a prevalent design for upgradeable smart contracts. At its core, it utilizes `delegatecall` to delegate the execution of function calls to an implementation contract.
The mechanism involves two contracts: Proxy and Implementation. When you send a transaction to Proxy Contract, it forwards it to Implementation Contract using `delegatecal`, thereby preserving the context (e.g., msg.sender and msg.value) of the calling transaction.
Example of transparent proxy smart contracts:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Proxy contract
contract TransparentProxy {
address public implementation;
address public admin;
constructor(address _implementation, address _admin) {
implementation = _implementation;
admin = _admin;
}
fallback() external payable {
address _impl = implementation;
require(_impl != address(0));
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
// Sample implementation contract
contract MyContract {
uint public value;
function setValue(uint _value) external {
value = _value;
}
}
Pros | Cons |
More gas-efficient for upgrades. | Requires strict storage management to avoid storage clashes. |
Allows for more flexibility in contract design. | Not as common as the transparent proxy, but gaining traction. |
UUPS is a more gas-efficient way of achieving upgradability. Instead of defining storage in the proxy contract, it uses the same storage layout as its implementation contract. This design makes the upgrade cheaper but requires more care in terms of not rearranging the storage layout in upgraded contracts.
Nowadays, smart contract developers opt for UUPS. They offer the same versatility as transparent proxies but are more cost-effective to launch because the upgrade logic isn’t housed in the proxy.
Example of UUPS:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// UUPS Proxy contract
contract UUPSProxy {
address public implementation;
address public admin;
constructor(address _implementation, address _admin) {
implementation = _implementation;
admin = _admin;
}
function _delegate(address _impl) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
fallback() external payable {
_delegate(implementation);
}
}
// Sample implementation contract
contract MyContract {
uint public value;
function setValue(uint _value) external {
value = _value;
}
}
Diamonds introduce a cascade mechanism that directs function requests to their corresponding implementation contracts through a mapping system. This system breaks down logic, including the upgrade interface, into specialized implementation contracts, known as facets, each having its unique address recorded in the mapping.
Overall, diamonds reduce potential pitfalls associated with the UUPS upgrade approach:
In essence, the Diamond structure offers a comprehensive, flexible, and secure upgrade mechanism, enhancing the overall solidity and efficiency. But this is the least tried and tested method because of its novelty.
There’s no single standard for implementing USCs as many EIPs and 3rd-party libraries exist. We have collected relevant documentation for each standard.
OpenZeppelin’s Upgrade Plugins is a good solution that supports both Transparent Proxy and UUPS patterns.
Pros
Cons:
Implementation instructions:
Example of OpenZeppelin Upgrade:
const { deployProxy, upgradeProxy } = require('@openzeppelin/hardhat-upgrades');
async function main() {
const MyContract = await ethers.getContractFactory('MyContract');
const instance = await deployProxy(MyContract, [/* constructor arguments */]);
console.log('Deployed at:', instance.address);
// ... later ...
const MyContractV2 = await ethers.getContractFactory('MyContractV2');
const upgradedInstance = await upgradeProxy(instance.address, MyContractV2);
console.log('Upgraded to:', upgradedInstance.address);
}
main();
There’s no single standard for implementing upgradeable smart contracts correctly, which increases the risks of producing serious security weaknesses.
Missing Call: There’s a big risk of a missing call to initialize a contract and its dependencies. Normally, the constructor is run on deployment to initialize the contract and dependencies like Ownable. But it’s not the case in USCs. Developers may forget to initialize a contract and dependencies, leading to serious consequences, as in the case of Wormhole’s uninitialized proxy.
Storage Collisions: Modifying the storage layout during an upgrade might lead to storage collisions between implementation versions. Storage collision is when two distinct variables point to the same storage location, leading to unexpected and unintended results. This is one of the most important security issues.
Unauthorized Upgrades: The mechanism that allows the contract to be upgraded must be well-protected. If malicious actors gain control, they could replace the contract with a malicious version, compromising user funds or data.
Denial of Service (DoS) After Upgrade: If an upgrade is not properly tested, it might introduce vulnerabilities that could be exploited for DoS attacks.
Unprotected Initialization: Initialization functions should be protected so they can’t be called multiple times. If they aren’t, attackers could reset the contract state.
Initializable Implementations: Some implementations can be initialized more than once, allowing attackers to seize control or reset them.
If unchecked, those vulnerabilities can snowball into costly exploits. We strongly recommend undergoing a smart contract audit to ensure your USCs don’t contain any errors.
Technically speaking, upgradeability doesn’t alter the immutability of smart contracts; it simply introduces a new contract to replace the old one. However, there are different methods of implementing USCs.
The most widely used approach is a proxy contract. This method manages state variables and refers to an implementation contract for logic, permitting upgrades without affecting data.
Follow @hackenclub on 𝕏 (Twitter)
References:
https://www.usenix.org/system/files/usenixsecurity23-bodell.pdf
https://ethereum.org/en/developers/docs/smart-contracts/upgrading/
https://docs.openzeppelin.com/learn/upgrading-smart-contracts
Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.
Table of contents
Tell us about your project
10 min read
Discover
13 min read
Discover
13 min read
Discover