Imagine a world where a software is maintaining millions of dollars worth of money, but there is an exploit which allows the hacker to take all that money away. Now imagine you can't do much about it because you can't update your software, so all you can do is wait and watch or pull the plug of the whole server.
This is a world we are living in with the software/contracts being developed for the Ethereum blockchain.
Immutability of blockchain has its advantages in making sure that things are tamper proof and the whole history of change can be seen publicly and audited. When it comes to smart contracts, immutability of the contract code has its disadvantages which makes it hard to update in case of bugs. The DAO and the Parity Wallet exploit are a good example of why smart contracts should have the capability to upgrade.
There is no de facto way of upgrading a contract. Several approaches have been developed and I'm going to discuss one of the better ones in my opinion.
Understanding how contracts are called
In Ethereum virtual machine (EVM) there are various ways by which contract A can invoke some function in contract B. Lets go over them
-
call: This calls the contract B at the given address with provided data, gas and ether. When the call is made the context is changed to this contract B context i.e. the storage used will be of the called contract. The
msg.sender
will be the calling contract A and not the originalmsg.sender
. -
callcode: This is similar to call but the only difference is that the context will be of original contract A so the storage will not be changed to that of the called contract B. This means the code in the contract B can essentially manipulate storage of the contract A. The
msg.sender
will be the calling contract A and not the originalmsg.sender
. -
delegatecall: This is similar to callcode but here the
msg.sender
andmsg.value
will be the original ones. It's as if the user called this contract B directly.
Here delegatecall seems of interest, as this can allow us to proxy a call from the user to a different contract. Now we need to build a contract that allows us to do that.
Upgradeable smart contract
We will write a smart contract that has an owner and it proxies all calls to a different contract address. The owner should be able to upgrade the contract address to which the code proxies to.
This is what that contract would look like
Let's dissect the code
Let's dissect above code. function () payable public
is the fallback function. When a user calls a contract without a function call (when sending ether) or a function call that is not implemented, it all gets routed to the fallback function. The function can do whatever it wants to. As we don't know what functions our contracts will have, nor how will it change so this is the ideal place where we route all calls made by user to the required contract address.
Inside this function we have some EVM assembly, as what we want to do is not currently directly available in Solidity.
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
As the user can send data which would contain information about which function to call and what are the arguments of that function so we also need to send it to our implementation contract. In Solidity the address to free memory is stored at location 0x40
. We load the data at 0x40
address and assign it name ptr
. Then we call calldatacopy
to copy all the data passed in by user to memory starting from ptr
address.
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
Here we use the delegatecall
as discussed before to call the contract. The result returned is either 0
or 1
.
0
means that there was some failure so we need to revert. 1
means the contract code execution was successful.
let size := returndatasize
returndatacopy(ptr, 0, size)
As the execution of code might have returned some data so we need to return it back to user, here we copy the returned data to memory starting at ptr
address.
switch result
case 0 { revert(ptr, size) }
case 1 { return(ptr, size) }
Lastly we just decided whether to return or revert based on the result of delegatecall
.
We inherit the contract from Ownable so the contract will have an owner. We expose updateImplementation(address)
function to update the implementation and it can only be executed by the owner of contract.
Deploy an upgradeable contract
First deploy the UpgradeableContractProxy
contract. Let's say we have a global calculator contract, which has list of numbers in storage and we can get the sum of all numbers in it.
Let's take that contract and deploy it. It should give us some address, now use that address as one of the parameters to call updateImplementation
of the previously deployed UpgradeableContractProxy
contract. Now our proxy should point to the deployed GlobalCalculator_V1
contract.
Now use the GlobalCalculator_V1
contract interface (ABI) to call the proxy contract. So if you are using web3js then you can do the following
const myGlobalCalculator = new web3.eth.Contract(GlobalCalculator_V1_JSON, 'PROXY_CONTRACT_ADDRESS');
Now when you make a function call to proxy, it will delegate the call to your global calculator contract. Let say you called few addNum(uint)
to add numbers to the _nums
list in storage and now it has [2, 5]
as value.
Let's add a multiplication functionality in your updated contract. We create a new contract which inherits from GlobalCalculator_V1
and add that feature.
Let's deploy GlobalCalculator_V2
contract and call updateImplementation(address)
of the proxy to point to this newly deployed contract address.
You can use the GlobalCalculator_V2
contract interface (ABI) to call same proxy contract which would route the call to new implementation.
const myGlobalCalculator_v2 = new web3.eth.Contract(GlobalCalculator_V2_JSON, 'PROXY_CONTRACT_ADDRESS');
You should have the ability to call getMul()
now. One thing to notice is that if I call getSum
it will return 7
as the _nums
in storage is still the same as the old one and have value [2, 5]
. So in this way our storage is maintained and we can add more functionality to our contract. The users also don't have to change the address as they always call the proxy contract which delegates it to other contracts.
Pros
- Ability to upgrade code.
- Ability to retain storage, no need to move data.
- Easy to change and fix.
Cons
- As we use fallback function to delegate so ether can't be directly sent to contract (due to not enough gas when using
.transfer
or.send
). We need to have a specific function that needs to be called for receiving ether. - There is some gas cost for executing the proxy logic.
- Your updated codes needs to be backwards compatible.
- The update capability is controlled by one entity. This can be solved by writing a contract that uses tokens and voting mechanism to allow the community to decide whether to update or not.
If you want to look at an example project using truffle which is testing an upgradeable contract see this.
Disclaimer
Don't use the above contract for production as it's not audited for exploits. It is only provided here for educational purpose.