There are two common reasons for wanting smart-contract-upgradability — new features and security (bugs). Upgradeable contracts are more scalable and secure since they can be updated to keep up with state-of-the-art, community-wide practices and standards.
Since Firefly is a customizable and extensible protocol, it must provide the flexibility to change pieces of core business logic through the decentralized governance process. That's why OpenZeppelin's thoroughly audited, open-source upgradability framework allows for cutting-edge, upgradable smart contracts with a high level of security. In this article, Firefly's Insurance Fund and Governance contracts will serve as examples of how upgradable contracts work under the hood.Figure I: A smart contract before and after an upgrade.
By design, smart contracts are immutable. This powerful feature prevents someone with malicious intent from changing a trusted contract. However, it can also be an obstacle hindering the ability to add features and bolster security. On the other hand, upgradability requires an admin address to be trusted with the responsibility of modifying contracts correctly. This adds a central point of failure and goes against the trustless nature of decentralized applications. To make upgradability possible while keeping the Firefly protocol trustless, the ownership of the contracts will be transferred to Firefly's decentralized Governance
Equipped with this understanding, let's now dive into the main topic: how contracts can be made upgradeable. Upgradable contracts are achieved through a proxy pattern, which requires two separate contracts for one upgradable one. As illustrated below, a "proxy" contract acts as an interface between the outer world (users) and an "implementation" contract that stores the core business logic.Figure II: User interacts with a proxy contract, which delegates function calls to the upgraded contract.
A user interacts with the proxy contract, which diverts function calls it receives to the implementation contract as shown in Figure II.
The proxy also has a few important publicly exposed functions such as
(same function with two naming conventions) that allow users to change the admin of the proxy. The admin
can be an address belonging to a single user, a multi-sig wallet, or another contract. Only the current
admin of the proxy can transfer adminship so these functions generally have the
modifier applied to them.
The admin is also the only address that may invoke the
setImplementation function on the
proxy contact that updates the address of the implementation contract stored within the proxy. Once
updated, the proxy contract routes all function calls to the new contract as seen in Figure III. This
design allows users to continue using the same point of access (the proxy contract) while also having
access to the new implementation contract.
Another important and difficult process required to perform a successful contract upgrade is the migration of the current implementation contract's state to the new contract's state. If the new contract does not have the same state, data, and parameters as the old one, it could result in a large loss of data or at worse crash the entire protocol. This is analogous to database migrations when updating backend servers for an application. If an application is moved from AWS to Azure and the database is not also migrated, the application will lose all previous data such as client preferences, balance, etc.
Writing code for proxy contracts and migrations is not a trivial task; it requires dedicated time and effort to ensure an upgrade can be pushed successfully when needed while retaining its previous state. This is where OpenZeppelin's Upgrades Plugin shines - it provides a framework to build, test, deploy and upgrade contracts in a secure and orderly fashion. The Upgrades Plugin implements the aforementioned proxy design and logic, allowing developers to instead focus on updating the business logic of the implementation contract. It does so by enforcing users to follow these framework guidelines. A few of the main ones are:
- An upgradeable contract must derive from base upgradable contracts (where needed)
- An upgradeable contract must implement its own public initializer that may be invoked only once - when deploying the first implementation contract
- An upgradeable contract's newer implementation can not change the order of storage variables
These guidelines and many others outlined by the framework are followed to make Firefly's Insurance Fund and Governance contracts upgradeable. Apart from the DETToken contract, not upgradeable due to important design considerations, all other contracts can be easily upgraded via a governance proposal. These contracts include:
- InsuranceFund: Allows users to stake USDC and earn rewards in DET
- TokenVesting: Allows users to create vesting contracts and aids in distributing tokens vested over a period of time
- Governance: Allows users to create proposals to upgrade the protocol
- TimeLock: Allows governance to execute the actions proposed in a proposal
Figure V shows the ownership/adminship graph; the TimeLock Contract is the admin of all proxies
its own. When a proposal is initiated and accepted through voting on Governance, the
TimeLock Contract is
called upon by the Governance to execute the actions specified in the proposal. The self adminship of
TimeLock is a special case that was thoroughly researched, verified, and tested before
exists to serve the case when an upgrade proposal to upgrade the TimeLock Contract is passed on
Governance. In such a scenario, the TimeLock Contract itself will have to upgrade its
address by calling the
upgradeTo function of its proxy since it is under the
Before a proposal can be presented to governance, the proposer must deploy the new implementation on the network like this:
// The proposer must first prepare and deloy the new implementation of the contract // in order to upgrade // token vesting contract deployed on local/testnet/mainnet const tokenVesting:TokenVesting; const proxyAddress:string = tokenVesting.address // returns the address of proxy // gets the new implementation for TokenVesting const factory = await hardhat.ethers.getContractFactory(`TokenVestingV2`) // openzeppelin upgrade function first checks if the new implementation // adheres to upgrade plugin rules and deploys on the network and returns the address const newImplAddress = await hardhat.upgrades.prepareUpgrade(proxyAddress, factory)
Once the new implementation is deployed, a proposal can be proposed in Governance to update the proxy contract of TokenVesting having the following actions:
const values = 0; // address of the proxy const targets = tokenVesting.address; // signature of the function const signatures = "upgradeTo(address)"; // argument to upgradeTo is the address of new implementation const callData = encodeParameters(["address"], [newImplAddress]); // governance contract deployed on local/testnet/mainnet const governance:Governance; // proposes an upgrade for TokenVesting await governance.propose( targets, values, signatures, callDatas, "Upgrading TokenVesting to V2" ); // once the proposal is successfully approved by majority of DET holders, // it can be executed via governance, which internally calls TimeLock to execute // each function call in proposal await governance.execute(<proposalId>)
Once the proposal has accumulated enough votes, the Governance executes the proposal.
TimeLock to execute the
upgradeTo function on TokenVesting Contract proxy and
implementation to the new address.