Upgrading and Migrating Smart Contracts on Ethereum

In this post I’ll dive into strategies for architecting smart contract applications that are both upgradeable and secure.

The value of Ethereum as a computation platform is code immutability. Smart contracts deployed to the network are ‘set in stone’ – their logic can not be changed, and lives forever at the contract address. In some cases, the contract can be destroyed by an authorized user – but a deployed contract’s code can not be edited or tampered with.

This ‘tamper-proof’ logic enables all the amazing applications that we see being built on Ethereum today.

However, it also make it hard for developers to design smart contracts and dApps that can be upgraded after being deployed.  

The Need for Upgradeability

In general, software developers strongly prefer upgrade capability in our apps. It allows us to:

  • Fix bugs, exploits and vulnerabilities in our contracts. Ethereum is a network of value transmission, and vunerabilities can be disasterous – resulting in millions of dollars worth of Ether stolen or lost.
  • Maintain a fixed contract address – important if your contract interacts with a 24/7 service such as an exchange, or DeFi product
  • Lay the foundation for a dApp that we expect to require updates or new features

Since the logic of a given smart contract on Ethereum can not be altered, we must design systems that allow contracts to be safely swapped out for newer versions, while maintaining data integrity and app security.

Designing Upgradeable dApps – Here Be Dragons

Making dApps upgradeable inevitably means increasing their complexity. Common upgradeability patterns involve splitting logic and data into seperate contracts, swapping new contracts for old, maintaining correct pointers, and the use of additional proxy contracts.

As a rule, in designing decentralized applications and smart contracts, we want to keep complexity to a minimum: the more connected components in our dApp, the more opportunities for hidden bugs and unintended interactions between contracts. Complexity means higher attack surface, and when our dApps move money around, attack surface can mean millions of dollars at risk.

DelegateCall – Be Really Careful

A common upgradeability pattern uses the delegatecall method. Typically, a proxy contract holding critical dApp data will invoke functions from a separate upgradeable logic contract, via the delegatecall opcode.

Delegatecall executes the called contract’s code within the context of the calling contract. Delegate call is ‘state-preserving’ – when invoked, any reference in the logic to a storage slot at position X, will point to the storage slot at position X in the calling contract.

If there is a mismatch in storage variable ordering between calling and called contracts, delegatecall can have unexpected results: overwriting important state variables, passing checks when they should fail, triggering balance underflows, etc. There are many attacks that can exploit this.  

Here’s an example of a delegatecall vulnerability:

contract Proxy {
    address public ownerAddress = 0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C;  
    address public logicAddress = 0x0DCd2F752394c41875e259e00bb44fd505297caF;  
    uint public foo;                                                            
    
    function invokeSetFoo(uint num) public onlyOwner {
        logicAddress.delegatecall(
         abi.encodePacked(bytes4(keccak256("setFoo(uint256)")), num)
        );
    }
    
    modifier onlyOwner() {
        require(msg.sender == ownerAddress);
        _;
    }
}

contract Logic {
    uint public foo;        
    address public proxyAddress = 0xdd1F635Dfb144068f91D430c76f4219088Af9E64; 

    function setFoo(uint num) public onlyOwningProxy {
        foo = num;
    }

    modifier onlyOwningProxy() {
        require(msg.sender == proxyAddress);
        _;
    }
}

Above, we have a proxy contract and a logic contract. The developer intends for the logic contract to be able to change the state of the variable ‘foo’ in the proxy. Note, however, the order of the state variables in each contract:

  • In the logic contract, foo is stored at slot 0
  • In the proxy contract, the owner’s address is stored at slot 0, and foo is stored at slot 2

When invokeSetFoo() is called in the proxy with x = 1, it calls setFoo() in the context of the proxy – and the value 1 is written to storage slot 0. However, in the proxy, foo is not stored at slot 0 — the owner’s address is at slot 0. So the effect of the call is to leave foo in the proxy unchanged… but the owner’s address is overwritten, and gets set to 0x0000000000000000000000000000000000000001!

This is a terrible result, which entirely breaks the dApp: only the owner can call invokeSetFoo() in the proxy, and only the proxy contract can call setFoo() in the logic. After one call to invokeSetFoo(), we have overwritten the owner’s address in the proxy, and thus completely lost control of our own dApp.

Be especially careful when using delegatecall in an upgradeable contract design. Make sure you understand how storage slots work in Solidity, and how delegatecall imports code from the called contract and executes it in the context of the calling contract.

General Principles for Upgradeability

  • Keep the contract structure and upgrade process as simple as possible
  • If using delegatecall, be sure you really understand execution contexts, and the layout of state variables in storage
  • Stick to proven patterns such as the OpenZeppelin upgradeability templates
  • If possible, avoid rollling your own low-level calls and assembly code

Consider Contracts Migrateablity Too

An alternative to upgrading contracts is contract migration. This is worst-case scenario planning – if things go south, a contract designed with migration in mind makes it as smooth as possible to transfer the full data storage to a new contract at a new address. We’ll cover contract migrations later.

Contract Upgrade Patterns

Let’s consider two smart contract upgrade patterns: Logic-Data, and Proxy-Logic. We’ll look at the simple Logic-Data pattern first, then dig in to the more widely used Proxy-Logic pattern.

The Logic-Data Pattern

Here, we split our application into two smart contracts. The logic contract contains only functions, and the data contract contains our state variables.

This pattern is suitable if you think you won’t need new state variables in future upgrades.

The pattern has the following properties:

  • The logic contract owns the data contract – only the logic contract can execute transactions that write to the data contract
  • The logic contract is Pausable – i.e. it inherits from the OpenZeppelin Pausable.sol contract, or implements identical functionality
  • Ownership of the data contract can be transferred to a new logic contract, from an authorized dApp admin address
  • The data contract’s functionality is limited to getters and setters for its state variables

To upgrade:

  • Deploy the new logic contract
  • Pause the old logic contract
  • Change the ownership of the data contract to the new logic contract

If your application requires a fixed address, you’ll need to have an intermediate proxy contract which forwards user calls to the logic contract. Then, simply point the proxy to the new logic contract when you upgrade. The Proxy-Logic-Data structure is still fairly simple, but does incur higher gas costs.

Data Storage in the Logic-Data Pattern

The simpler the storage pattern, the better. In the data contract, getters and setters for each state variable are preferred over mappings of variable names to values.

A contract full of getters and setters might look clunky, but they are simple to use and audit.

The problem with mappings is that they only allow one value datatype. When the contract has state variables of multiple datatypes (very likely), we either need to store all the values in one mapping as bytes32 values and then convert them back in the logic contract, or we group state variables by type and store them in seperate mappings for each type. Either way, data ends up stored in an obscure, counterintuitive way, and requiring type conversions introduces unnecessary complexity.

Advantages of the Logic-Data Upgrade Pattern

  • Simple structure. Two contracts (or three with a proxy), and only one contract to be swapped out.
  • Simple flow of function calls between Logic and Data
  • Avoids all the pitfalls of delegatecall

Drawbacks

  • Logic contract doesn’t have a fixed address, unless you add an intermediate proxy contract (and gas costs)
  • Doesn’t allow addition of state variables in an upgrade

The Proxy-Logic Pattern

The most widely known upgrade pattern involves a proxy contract, which holds state variables, and a logic contract, which can be swapped out in an upgrade. Users send transactions to the proxy, which calls the appropriate function in the logic contract, via delegatecall.

The proxy contract uses a custom fallback function to handle user calls. The proxy itself doesn’t contain functions that match the logic contract – The fallback function imports the appropriate function from the logic contract with the signature that matches the user’s function call. The function call is then executed in the context of the proxy. msg.value and msg.sender values do not change, and any resultant state updates are applied to the state variables in the proxy contract’s storage.

The obvious challenge here is the use of delegatecall. Any state variables needed in the proxy contract, must be taken into account in the logic contract. If the order and type of these state variables do not exactly match between proxy and logic contract, you risk erroneously overwriting stored data in the proxy contract. Storage mismatches with delegatecall expose your dApp to subtle but potentially devastating bugs.

Most state variables can be held in the logic contract, but the proxy will likely need at least two: the owner’s address, and the logic contract instance address.

Upgradeable contracts have to be aware of the storage structure declared in the proxy contracts, and steps must be taken to guarantee these do not get overwritten.

Because this pattern is the most widely used, there are well-proven templates – namely from OpenZeppelin. If you follow their approach to the letter, you should avoid delegatecall-based bugs.

Handling Proxy Data Storage

There are different ways to handle data storage in the proxy contract. OpenZeppelin currently recommend the ‘unstructured storage’ pattern. It works like this:

  • Critical state variables are held in the proxy – for example the owner’s address, and the logic contract address.
  • The storage position of each proxy state variable is computed as hash h – by hashing a unique, relevant string (“Owner’s address”, “Logic instance address”, etc).
  • Make sure this string is long and unique enough to not be used as a variable name in future logic contract upgrades
  • The value of the variable (the actual address) is assigned to the storage slot at position h, using assembly code

Since smart contracts on Ethereum have 2^256 possible storage slots, and future state variable names in the logic contract will not match the hashed string, we can be very confident that no future logic contracts will write data to this slot at position h. Thus, the critical state variables in the proxy contract are protected from overwrite.

To upgrade, simply pause the old logic contract, deploy the new one, and point the proxy to the new logic contract instance.

The OpenZeppelin implementation of this unstructured storage pattern contains functions for computing storage position and assigning a value to that position.

Ensuring Data Integrity Across Upgrades

Importantly, new logic contracts must maintain the same storage layout as their predecessors. An upgraded logic contract L’ should inherit from its predecessor L: v2 inherits from v1, v3 inherits from v2, etc. This guarantees that any new state variables declared in L’ will be initialized at new storage slots.

Benefits of the Unstructured Storage Proxy Pattern

  • Simple structure – only two contracts involved
  • Allows addition of state variables in future logic contracts
  • Protects critical state variables in the proxy

Downsides

  • Complex code. It involves both assembly code, and delegatecall. The more you modify/extend the OpenZeppelin proxy template, the more you expose your contract to bugs.
  • Logic contracts must always inherit from predecessors

Upgradeability – Is there a Better Way?

All the above upgrade patterns come with a level of risk.

And in a sense, they also cut across the principles of Ethereum development. The whole premise of programmable blockchains is to create trust-minimized, ‘set-in-stone’ smart contracts – immutable code, and irreversible logic. The new applications that Ethereum promises – stablecoins, tokenized assets, unique digital items – they’re all made possible by these properties.

An easily upgradeable application, with expectations of new code and new features, can undermine the benefits of using the blockchain. The more easily upgradeable your contract, the less trust users have that it really is decentralized and immutable. If a central owner can easily change features or logic of the app every week, users may be justified in asking ‘why is this app actually on the blockchain?’

Fortunately it’s possible to write secure smart contracts with safety nets for worst-case scenarios and hack/exploit recovery.

Migration Architecture – Worst Case Scenario Insurance

In the event of hack, exploit or some other failure mode of our dApp, we want to be able to:

  • Freeze the contract
  • Smoothly and swiftly move all data to a new contract
  • Let your users know the new contract address.

Reasons for migrating our contract include significant upgrades to data and logic – for example, patching a critical bug in contract functionality, while also resetting user token balances after a hack.

Show your Users you’re Serious About Trust-Minimization

Migration is a ‘worst case scenario’ escape hatch. It enforces the need for a new address, and the migration process costs time and money. Thus, users can have confidence in your smart contracts, and expect that updates will be infrequent and only occur in the case of hack or critical bug.

The Migrateable Contract Design Pattern

Making a contract migrateable is easy with a little forethought. Simply:

  • Implement the ability to freeze the contract: make the contract Pausable by it’s owner
  • Emit events whenever a value is stored in a mapping, for data recovery later
  • Add a ‘pre-launch’ mode to contract, in which only owner can update the contracts’ stored data via a batchTransfer() function, and users can’t take any action until the contract is set to production mode

Migratability slightly increases operational gas costs due to event emission – a single event costs 325-1875 gas, depending on the number of arguments, but the safety net provided by migration is arguably worth it.

The pre-launch mode allows the owner to import data from the old contract before re-launch. The pre-launch mode should be invokable only before the contract is live, and inaccessible after. This ensures that the contract is provably ‘tamper-proof’, and that admins can’t arbitrarily alter user data in the production mode.

Contract Migration Process

Provided you’ve built a migrateable contract according to the points above, the migration process is fairly straightforward:

  1. Freeze the source contract
  2. Recover the data to be migrated
  3. Deploy a new destination contract in the pre-launch mode
  4. Migrate the data from source to destination
  5. Test the data at the migrated contract
  6. Set the newly deployed contract to production mode
  7. Update all relevant businesses – exchanges, wallets, other dApps, etc – with your new contract address

The tricky steps here are 2) and 4) – recovering the data, and writing it to the new contract.

Recovering Your Contract Data

For recovery, public variables can be easily grabbed via their getter functions. Array data and private variables can be grabbed by computing their storage location (based on type and order of declaration in the contract), and calling the getStorageAt() function.

Note: you’ll need call these functions with the most recent valid block number for your contract – i.e. if it has been hacked, use the block number before the hack occurred.

Recovering data from mappings is trickier, because mappings don’t actually ‘store’ keys (rather, the key’s value is written to contract storage at the memory location keccak256(key . p), where p is the starting storage position of the mapping, and ‘.’ is string concatenation).

That’s why a migrateable contract should emit an event when data is written to a mapping. To make life easy, you can create an off-chain event listener via web3 that grabs every event emitted by the contract and saves them in your web app’s backend.

Writing Your Data to the Destination Contract

With the contract in pre-launch mode, successively call the batch transfer function on your contract, passing blobs of data from the original.

Be mindful of the block gas limit – you may need to perform several batch transfers to successfully migrate all the data.

Before setting your contract to live mode, test the stored data thoroughly to ensure it matches data from the source contract, and that the migration has completed successfully.

Writing the new data will necessarily incur costs, which may be significant. This cost, however, may work in your favor: a credible price on contract migration can feed users’ confidence in the ‘tamper-proof-ness’ your dApp.

Summary – Upgrading and Migrating Smart Contracts

  • Ethereum’s strength is code immutability – but this makes it hard to upgrade smart contracts
  • With the right upfront design choices, smart contracts can be made upgradeable for implementing features/updates, and migratable as an insurance policy against hacks and critical bugs
  • Increased complexity leads to increased attack surface – so make your upgrade/migrate functionality as simple and clear as possible
  • The Logic-Data upgrade pattern is suitable if your state variables won’t change. Otherwise, prefer Zeppelin’s ‘unstructured storage’ Proxy-Logic pattern
  • Consider whether you really need easy upgrade capability. It may detract from your dApp’s perceived trust-minimization and decentralization
  • Use the migration pattern for worst-case scenario insurance
  • Contract migration require pausability, thorough event logging, and an owner-only ‘pre-launch’ mode for transferring data.

Leave a Reply

Your email address will not be published. Required fields are marked *