Upgradeability Is a Bug

tl;dr

  1. Smart contracts are useful because they’re trustless.
  2. Immutability is a critical feature to achieve trustlessness.
  3. Upgradeability undermines a contract’s immutability.
  4. Therefore, upgradeability is a bug. (But there are mitigations!)

Why do we need smart contracts?

I’d like to offer you the investment opportunity of a lifetime. If you send me 1 ether today, I will send you 2 ether tomorrow. Are you in?

Although there’s some evidence that people will take such an offer, I hope that you will refuse. You simply have no reason to trust me to hold up my end of the bargain.

Enter smart contracts. Smart contracts let us engage in all sorts of transactions without having to trust each other. Below is a simple Ethereum smart contract we could use for this investment opportunity. If it has 1 ether in it, you can send 1 ether of your own and withdraw 2 ether tomorrow:

contract InvestmentOpportunity {
    address public investor;
    uint256 public payday;
    
    constructor() public payable {}

    function invest() external payable {
        require(investor == address(0), "Someone beat you to it!");
        require(msg.value == address(this).balance / 2,
            "You must match the contract balance.");
            
        investor = msg.sender;
        payday = now + 24 hours;
    }
    
    function withdraw() external {
        require(msg.sender == investor,
            "Only the investor can withdraw.");
        require(now >= payday,
            "You must wait until the payday time.");        

        msg.sender.transfer(address(this).balance);
    }
}

If I deployed that contract to the main Ethereum network and put one ether in it, you’d be crazy not to invest. By encoding the rules into a smart contract, we’ve made our financial transaction “trustless”.

(This “investment” scenario is a bit unrealistic, but I’m going to stick with it because the code is simple. If you prefer, imagine the similar scenario of peer-to-peer gambling.)

The importance of immutability

When I offered you an investment opportunity earlier in this article, you refused because you didn’t know what I was going to do. Was I going to pay you, like I promised, or was I going to run away with the money?

Now that we have a smart contract, you can safely accept the offer because you do know what the smart contract will do. You can read the code yourself, and Ethereum guarantees that this exact code will execute, because smart contract code is immutable.

There’s nothing I can do to affect the outcome, so you don’t need to trust me at all.

Upgradeability = mutability

Sooner or later, every developer who learns about smart contracts asks the question, “How do I fix bugs?”

Many developers have independently come up with ways to swap out code at runtime. Over the past year, standard approaches to upgradeability have become popular. Perhaps the most common is the “proxy pattern”.

Below is a version of our investment opportunity contract that I’ve made upgradeable using the proxy pattern. The Base contract keeps track of state variables, the Implementation provides the business logic, and the Proxy allows the implementation to be replaced at runtime:

contract Base {
    // proxy state
    address owner;
    address implementation;

    // implementation state
    address public investor;
    uint256 public payday;
}

contract Implementation is Base {
    function invest() external payable {
        require(investor == address(0), "Someone beat you to it!");
        require(msg.value == address(this).balance / 2,
            "You must match the contract balance.");
        
        investor = msg.sender;
        payday = now + 24 hours;
    }
    
    function withdraw() external {
        require(msg.sender == investor,
            "Only the investor can withdraw.");
        require(now >= payday,
            "You must wait until the payday time.");
        
        msg.sender.transfer(address(this).balance);
    }
}

contract Proxy is Base {
    constructor(address _implementation) public payable {
        owner = msg.sender;
        implementation = _implementation;
    }

    function setImplementation(address _implementation) external {
        require(msg.sender == owner);
        implementation = _implementation;
    }
    
    // adapted from https://github.com/zeppelinos/labs/blob/master/upgradeability_using_eternal_storage/contracts/Proxy.sol
    function() external payable {
        address impl = implementation;
        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) }
         }
    }
}

If I deployed that Proxy contract (using that Implementation) to Ethereum and put an ether in it, would you be willing to invest?

Hopefully, you’ll once again refuse. Because the contract code is no longer immutable, you no longer know what the contract will do. Reading the code doesn’t help you, because I can change that code at any time.

Below is an alternate implementation I might “upgrade” to:

contract ExitScam is Base {
    function exit() external {
        require(msg.sender == owner);
        selfdestruct(msg.sender);
    }
}

Note that in this particular example, your ether is locked up for 24 hours, so I have a luxurious time window in which to make my changes. But even if the payout were immediate, I could always race against your transaction and try to change the implementation first.

Mitigations

If upgradeability is a bug, what should be done about it?

Just say no

My first suggestion—and I realize this is controversial, even within my team—is that you probably shouldn’t make your contracts upgradeable. Most of the time, adding upgradeability will not actually help you, it will undermine the value of your smart contract, and it may be a source of bugs itself.

Limit mutability

If you find that upgradeability is a real requirement for you, consider limiting contract mutability to changes that can’t harm anyone.

For example, in the contract described in this article, I could require that code could only be changed after emitting an event and a 48-hour delay. Because funds are only locked for 24 hours, this would leave plenty of time for a user to discontinue their use of the contract if they didn’t like the pending code change.

Use parameters

Parameters are another good way to limit contract mutability. The investment opportunity contract we started with doubles an investor’s money. This multiplier could be safely changed at runtime as long as it is only increased. No one would mind if I changed the code to triple or quadruple investments instead.

Migration, not mutation

Finally, the most common means of upgrading a smart contract is to deploy a new one. If the new contract is better for everyone, users will be happy to stop using the old contract and use the new one instead. If this swap involves reading state from the old contract, this is sometimes called the “migration pattern”.

More sophisticated patterns exist for migration. For example, some contracts use a proxy for upgrade but require that users opt-in before they get the new implementation.

Final thoughts

To a developer, the allure of upgradeability is obvious. It’s great to be able to fix bugs and add features after a contract is deployed, but this ability itself undermines a critical feature of smart contracts.

Keep in mind that users of your smart contract* don’t trust you*, and that’s why you wrote a smart contract in the first place.

More posts chevronRight icon