Proxy Contracts: Creating mutable services on an immutable platform
Upgrade and structure your service using proxy contracts
In the blockchain world where “code is law,” irreversible losses can easily occur with smart contract exploits. At the same time, code on the blockchain is immutable. So what do you do?
Auditing contracts can provide high confidence, but if you want to change code logic in the future, consider creating a proxy contract.
What are proxy contracts?
Proxy contracts are contracts that reroute calls to other contracts. Similar to an API gateway, a proxy contract can serve as a single endpoint for callers and redirect calls to other resources. As shown in the diagram below, the proxy contract can forward calls to logic (aka implementation) contracts, contracts that contain the logic of the service.
If a bug is found or a feature upgrade is needed in the logic contract, the smart contract owner can simply deploy a new logic contract and submit a transaction to the proxy contract to update its logic contract address. By doing so, the proxy contract will start rerouting calls to the new logic contract, thereby “upgrading” the service.
I’m interested… how do proxy contracts work?
At a low level, proxy contracts reroute data by using the EVM’s delegatecall
opcode, which allows a contract to execute code of another contract. Here, it is important to note that delegatecall
executes the callee’s code in the context of the caller’s state. This information makes upgrades possible, since the proxy contract maintains the service data (aka state). Therefore if our service is an NFT collection, the token balances and URIs will be stored in the proxy contract, while the logic on how to mint and transfer tokens will be stored in the logic contract. Upgrading the service may change who can mint tokens, but the token balances will remain the same since the proxy contract remains constant.
Though writing everything in the proxy’s data prevents data loss during upgrades, we now have to worry about the logic contract overwriting existing proxy contract data. The proxy contract maintains important data such as the logic contract address, and so it is crucial that the logic contract does not write token metadata in the same data storage slot. The most common and simplest way to solve this is through the “Unstructured Storage” proxy pattern, where a random storage slot is chosen for variables in a proxy contract. This slot is sufficiently random so the probability of a logic contract declaring a variable at the same slot is negligible. An Ethereum standard for this exists as EIP-1967: Standard Proxy Storage Slots, and OpenZeppelin provides implementation for this standard.
Got it! What are the different types of proxies I can use?
The two main proxy patterns to look at are the UUPS proxy pattern and beacon proxy pattern. OpenZeppelin implementations for both exist. In essence, both proxy contracts re-route calls to logic contracts. However, they each have its own merits:
UUPS (Universal Upgradeable Proxy Standard) proxy pattern
A UUPS proxy inherits the logic contract address location, and holds the upgrade logic in the logic contract.
The UUPS proxy was created as an improvement to the transparent proxy, which holds the upgrade logic in the proxy contract. This makes UUPS proxies cheaper than transparent proxies to deploy and send transactions with.
Since the upgrade logic exists in the logic contract, the service has the option to freeze logic changes by removing the upgrade logic in the final logic contract version.
Beacon proxy pattern
In the beacon proxy pattern, three main components exist: the beacon contract, beacon proxy contract, and logic contract(s). The beacon contract holds the address of the logic contract, and the beacon proxy contract retrieves the address from the beacon contract to reroute calls to. While this may seen redundant at first, if more than one beacon proxy points to a single beacon, batch proxy upgrades can happen by updating the logic contract address stored in the beacon.
For n NFT collections spread across k beacon contracts, only a total of k blockchain calls need to be made for an upgrade.
The gas costs of the different types of proxies are nicely summarized here.
Great, how do I implement my proxy?
OpenZeppelin provides Upgrades Hardhat plugins for proxy contract implementation. The step-by-step tutorial for Hardhat can be found in this post.
Alternatively, using the OpenZeppelin APIs and libraries for upgrades, we can deploy our contract directly. Here are some contract examples:
Beacon.sol - Beacon contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
contract BeaconContract is UpgradeableBeacon {
constructor(address _logicAddr) UpgradeableBeacon(_logicAddr) {}
}
BeaconProxy.sol - Beacon proxy contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
contract BeaconProxyContract is BeaconProxy {
constructor(address _beacon) BeaconProxy(_beacon, "") payable {}
function setBeacon(address _beacon) public {
_setBeacon(_beacon, "");
}
function beacon() public view virtual returns (address) {
return _beacon();
}
}
BeaconLogicContract.sol - Beacon logic contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol";
contract BeaconLogicContract is ERC1155Upgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// Automatically lock the implementation contract during deployment:
// https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract
_disableInitializers();
}
event ContractBalance(address indexed account, uint256 indexed id, uint256 balance);
event ContractVersion(string indexed version);
function initialize() public initializer {
__ERC1155_init("ipfs://baseUriPath");
}
function mint(address _to, uint256 _id, uint256 _amount) external {
_mint(_to, _id, _amount, "");
}
function balances(address _account, uint256 _id) public virtual {
uint256 _balance = balanceOf(_account, _id);
emit ContractBalance(_account, _id, _balance);
emit ContractVersion("V1");
}
}
UUPSProxy.sol - UUPS proxy contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract UUPSProxy is ERC1967Proxy {
constructor(address _logic) ERC1967Proxy(_logic, "") payable {}
}
UUPSCompatibleLogicContract.sol - UUPS compatible logic contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract UUPSCompatibleLogicContract is ERC1155Upgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// Automatically lock the implementation contract during deployment:
// https://docs.openzeppelin.com/upgrades-plugins/1.x/writing-upgradeable#initializing_the_implementation_contract
_disableInitializers();
}
event ContractBalance(address indexed account, uint256 indexed id, uint256 balance);
event ContractVersion(string indexed version);
function initialize() public initializer {
__ERC1155_init("ipfs://baseUriPath");
}
function mint(address _to, uint256 _id, uint256 _amount) external {
_mint(_to, _id, _amount, "");
}
function balances(address _account, uint256 _id) public virtual {
uint256 _balance = balanceOf(_account, _id);
emit ContractBalance(_account, _id, _balance);
emit ContractVersion("V2");
}
/**
* @dev See {UUPSUpgradeable-_authorizeUpgrade}.
* This function is called internally when upgrading contracts by the proxy.
* The modifier `onlyOwner` makes sure that only the owner of the contract can upgrade the contract.
*/
function _authorizeUpgrade(address) internal override {}
}
I’m ready to create my service using proxy contracts!
I hope that’s all the things you need to know about proxy contracts!
Blockchains are immutable, but your service doesn’t have to be.
Appendix
[1] Notable proxy storage patterns
Apart from Unstructured Storage, notable proxy storage patterns are:
Inherited Storage: Both the proxy and logic contract inherit the same storage structure to ensure that both adhere to storing the necessary proxy state variables. An in-depth explanation is provided by OpenZeppelin here.
Eternal Storage: Storage schemas are defined in a separate contract that both the proxy and logic contract inherit from. An in-depth explanation is provided by OpenZeppelin here.
Diamond Storage: Similar to Unstructured Storage, values are stored at arbitrary positions. Additionally, the Diamond Standard uses a lookup table for locations of the logic contracts. Diamond Storage is a work-in-progress defined in EIP-2535: Diamonds, Multi-Facet Proxy.
Integration with OpenZeppelin Contracts can be found here.
Trail of Bits has done an audit on the Diamond Standard, and the Diamond Standard team has since released a new version and published this article.
Aavegotchi and PieDAO are two main services that use/have used the Diamond Standard. Additional services that are using the Diamond Standard can be found here.
Your information is incorrect about Diamond Storage. OpenZeppelin has stated that they plan to use Diamond Storage in their next major version of OpenZeppelin here: https://github.com/OpenZeppelin/openzeppelin-contracts/issues/2964#issuecomment-1068278307
The Trail of Bits article refers to an older version of an implementation of the Diamond Standard which has since been updated. An article addresses all the concerns brought up by Trail of Bits here: https://eip2535diamonds.substack.com/p/poorly-written-trail-of-bits-article
There are many projects using EIP-2535, which is the Diamond Standard. Some of the projects can be found here: https://github.com/mudgen/awesome-diamonds#projects-using-diamonds
The louper.dev website counts 3,424 diamonds deployed so far.
Thanks for sharing another informative article. Eagerly awaiting for future noop articles!