πŸ“œ ⬆️ ⬇️

Updatable smart contracts on the Ethereum network

Motivation


Ethereum network contracts are immutable - once uploaded to the network (blockchain), they cannot be changed. Specificity of a business or development may require updating the code, but with the traditional approach this becomes a problem.


Popular reasons for updating



Description of the technical solution


The implementation of the required functionality - updating the code, is planned through the separation of the code into its components:


  1. Data - smart contracts without logic and providing only data storage space;
  2. Business logic - smart contracts describing the logic of extracting data from the repository and their changes;
  3. Entry points β€” Immuniable contracts keep track of business logic updates and provide the end user with a link to the current business logic contract.

Renewable smart counter contract


Imagine an abstract example cut off from reality - a counter with updated logic of magnification.



With the traditional approach and the initial knowledge of all stages, it would be necessary to make a field in the counter clearly indicating the current stage, for example: uint public currentState. Each time the counter increment method was called, the current stage would be checked and the code associated with it would be executed:


function increaseCounter() public returns (uint) { if (currentState == 0) { value = value + 1; } else if (currentState == 1) { value = value + 10; } return value; } 

In order to demonstrate the possibilities of renewable contracts, we agree that we will have a third stage, which we do not know yet, and we will describe its conditions at the end of the article.


Storage


To implement the data layer storing the current value of the counter and separated from the business logic, create a contract - ~/contracts/base/UIntStorage.sol :


Source url


 pragma solidity ^0.4.18; contract UIntStorage { uint private value; function setValue(uint _value) external returns (uint) { value = _value; return value; } function getValue() external view returns (uint) { return value; } } 

As the name implies and the implementation of the contract - the repository knows nothing about how it will be used and performs the task of encapsulating the uint private value field


Business logic


We agree that the interaction with our business logic will be implemented through two methods: increaseCounter and getCounter to increase the counter and get the current value, respectively, which we will explicitly describe in the interface - ~/contracts/examples/counter/ICounter.sol :


Source url


 pragma solidity ^0.4.18; interface ICounter { function increaseCounter() public returns (uint); function getCounter() public view returns (uint); } 

Next, we describe a smart contract of business logic from the first stage that implements the ICounter interface and uses the previously described storage - ~/contracts/examples/counter/IncrementCounter.sol :


Source url


 pragma solidity ^0.4.18; import "./ICounter.sol"; import "../../base/UIntStorage.sol"; contract IncrementCounter is ICounter { UIntStorage public counter; function IncrementCounter(address _storage) public { counter = UIntStorage(_storage); } function increaseCounter() public returns (uint) { return counter.setValue(getCounter() + 1); } function getCounter() public view returns (uint) { return counter.getValue(); } } 

It is important to note that IncrementCounter has no internal state (does not store data), except for the link to the repository.


If you agree to pass the link to the vault with the first argument to the increaseCounter and getCounter methods, you can implement the state-lesss business logic


Making changes to ~/contracts/examples/counter/ICounter.sol :


Source url


 pragma solidity ^0.4.18; interface ICounter { function increaseCounter(address _storage) public returns (uint); function getCounter(address _storage) public view returns (uint); function validateStorage(address _storage) public view returns (bool); } 

Now the business logic methods wait for the first argument with a reference to the repository, as well as implement the repository verification method for validity: validateStorage(address _storage)


Let's make changes to the implementation of the first stage - ~/contracts/examples/counter/IncrementCounter.sol :
Source url


 pragma solidity ^0.4.18; import "./ICounter.sol"; import "../../base/UIntStorage.sol"; contract IncrementCounter is ICounter { modifier validStorage(address _storage) { require(validateStorage(_storage)); _; } function increaseCounter(address _storage) validStorage(_storage) public returns (uint) { UIntStorage counter = UIntStorage(_storage); require(counter.isUIntStorage()); return counter.setValue(counter.getValue() + 1); } function getCounter(address _storage) validStorage(_storage) public view returns (uint) { UIntStorage counter = UIntStorage(_storage); require(counter.isUIntStorage()); return counter.getValue(); } function validateStorage(address _storage) public view returns (bool) { return UIntStorage(_storage).isUIntStorage(); } } 

Before moving on to implementing the next stage and updating the business logic contract, we will write a couple of tests and make sure that the business logic works as planned.


Testing


This repository is a project of the Truffle framework and provides convenient functionality for testing: truffle test .


I will not describe in detail the process of writing tests , but if you are interested in this topic, write to me at @alerdenisov telegrams and I will prepare an article with best-practice testing of contracts.


~/test/IncrementCounter.test.js :


 import expectThrow from './utils/expectThrow' const IncrementCounter = artifacts.require('./IncrementCounter.sol') const UIntStorage = artifacts.require('./UIntStorage.sol') const BoolStorage = artifacts.require('./BoolStorage.sol') contract('IncrementCounter', ([owner, user]) => { let counter, storage, fakeStorage before(async () => { storage = await UIntStorage.new() fakeStorage = await BoolStorage.new() counter = await IncrementCounter.new() }) it('Should receive 0 at begin', async () => { const currentValue = await counter.getCounter(storage.address) assert(currentValue.eq(0), `Uxpected counter value: ${currentValue.toString(10)}`) }) it('Should increase value on 1', async () => { await counter.increaseCounter(storage.address) const newValue = await counter.getCounter(storage.address) assert(newValue.eq(1), `Unxpected counter value: ${newValue.toString(10)}`) }) it('Should store 1 after increment', async () => { const storedValue = await storage.getValue() assert(storedValue.eq(1), `Unxpected stored value: ${storedValue.toString(10)}`) }) it('Should validate storage', async () => { await counter.validateStorage(storage.address) }) it('Should unvalidate fake storage', async () => { await expectThrow(counter.validateStorage(fakeStorage.address)) }) }) 

Running tests will show that everything is "fine":


  Contract: IncrementCounter βœ“ Should receive 0 at begin βœ“ Should increase value on 1 (63ms) βœ“ Should store 1 after increment βœ“ Should validate storage βœ“ Should unvalidate fake storage 5 passing (301 ms) 

But actually it is not. Let's add an intermediate test of "unauthorized" interaction with the repository:


  it('Should prevent non-authenticated write', async () => { await expectThrow(storage.setValue(100)) }) 

  Contract: IncrementCounter βœ“ Should receive 0 at begin βœ“ Should increase value on 1 (58ms) 1) Should prevent non-authenticated write 2) Should store 1 after increment βœ“ Should validate storage βœ“ Should unvalidate fake storage 4 passing (330ms) 2 failing 

Source url


Storage Ownership


The problem with the current solution is that the repository does not restrict the recording in any way and the attacker can change the data in the repository and ignore the business logic of the counter contract.


The main advantage of smart contracts is that they guarantee participants of the exchange that the data (state) will not be changed in any other way but declares the smart contract. But now the changes are not limited.


The task is to make it so that only the current business logic contract can change the storage.


To explicitly limit the interaction with the repository, Ownable 's use the Ownable pattern from the zeppelin-solidity (for more information on the pattern, see the framework documentation).


Inherit the storage from the Ownable contract and add the onlyOwner modifier to the setValue() method:


Source url


 pragma solidity ^0.4.18; import "zeppelin-solidity/contracts/ownership/Ownable.sol"; contract UIntStorage is Ownable { uint private value; function setValue(uint _value) onlyOwner external returns (uint) { value = _value; return value; } function getValue() external view returns (uint) { return value; } function isUIntStorage() external pure returns (bool) { return true; } } 

Congratulations, now only an associated storage owner can write to our storage! Now already 3 out of 6 tests fail! Let's give the business management of the storage in the β€œmanual” tests:


Source url


  before(async () => { storage = await UIntStorage.new() fakeStorage = await BoolStorage.new() counter = await IncrementCounter.new() await storage.transferOwnership(counter.address) }) 

Now all the tests pass, but the second question arises: "How to manage the ownership of the repository when updating business logic"


Shared controller


Before implementing the common controller, we will make another counter contract, but already the second stage - ~/contracts/examples/counter/IncrementCounterPhaseTwo.sol :


Source url


 pragma solidity ^0.4.18; import "./IncrementCounter.sol"; contract IncrementCounterPhaseTwo is IncrementCounter { function increaseCounter(address _storage) validStorage(_storage) public returns (uint) { UIntStorage counter = UIntStorage(_storage); return counter.setValue(counter.getValue() + 10); } } 

Now, when we have two implementations of the counter and Ownable storage, it becomes clear that it is necessary to somehow "ask" one implementation to give the other control to the storage. Add the transferStorage(address _storage, address _counter) method transferStorage(address _storage, address _counter) to the counter interface - ~/contracts/examples/counter/ICounter.sol :


Source url


 pragma solidity ^0.4.18; interface ICounter { function increaseCounter(address _storage) public returns (uint); function getCounter(address _storage) public view returns (uint); function validateStorage(address _storage) public view returns (bool); function transferStorage(address _storage, address _counter) public returns (bool); } 

ICounter agree that the final implementation of the ICounter should, after calling the transferStorage method, give control of the storage to the address passed to the _counter parameter:


Source url


  function transferStorage(address _storage, address _counter) validStorage(_storage) public returns (bool) { return UIntStorage(_storage).transferOwnership(_counter); } 

Let's finish the transfer tests with the new logic and check the result of the increaseCounter method after changing the logic:


  it('Should transfer ownership', async () => { await counter.transferStorage(storage.address, secondCounter.address); }) it('Should reject increase from outdated counter', async () => { await expectThrow(counter.increaseCounter(storage.address)); }) it('Should increase counter with new logic', async () => { await secondCounter.increaseCounter(storage.address) const newValue = await secondCounter.getCounter(storage.address) assert(newValue.eq(11), `Unxpected counter value: ${newValue.toString(10)}`) }) 

Running tests can give a false sense that everything works:


  Contract: IncrementCounter βœ“ Should receive 0 at begin βœ“ Should increase value on 1 (75ms) βœ“ Should prevent non-authenticated write βœ“ Should store 1 after increment βœ“ Should validate storage βœ“ Should unvalidate fake storage βœ“ Should transfer ownership βœ“ Should reject increase from outdated counter βœ“ Should increase counter with new logic (47ms) 9 passing (500ms) 

But I hasten to disappoint you, these changes again opened the green light to the attackers:


  it('Should reject non-authenticated transfer storage', async () => { await expectThrow(secondCounter.transferStorage(storage.address, user, { from: user })) }) it('Should reject increase from user fron previous test', async () => { await expectThrow(storage.setValue(100500, { from: user })) }) it('Should store 11 as before', async () => { const storedValue = await storage.getValue() assert(storedValue.eq(11), `Unxpected stored value: ${storedValue.toString(10)}`) }) 

  Contract: IncrementCounter βœ“ Should receive 0 at begin (46ms) βœ“ Should increase value on 1 (55ms) βœ“ Should prevent non-authenticated write βœ“ Should store 1 after increment βœ“ Should validate storage βœ“ Should unvalidate fake storage βœ“ Should transfer ownership βœ“ Should reject increase from outdated counter βœ“ Should increase counter with new logic (46ms) 1) Should reject non-authenticated transfer storage 2) Should reject increase from user fron previous test 3) Should store 11 as before 9 passing (611ms) 3 failing 

The main task of the common controller will control the transfer of rights and prevent anyone from this process. First, change the IncrementCounter by analogy with UIntStorage so that it also Ownable logic and limits interaction with the repository:


Source url


 pragma solidity ^0.4.18; import "./ICounter.sol"; import "../../base/UIntStorage.sol"; contract IncrementCounter is ICounter, Ownable { modifier validStorage(address _storage) { require(validateStorage(_storage)); _; } function increaseCounter(address _storage) onlyOwner validStorage(_storage) public returns (uint) { UIntStorage counter = UIntStorage(_storage); require(counter.isUIntStorage()); return counter.setValue(counter.getValue() + 1); } function getCounter(address _storage) validStorage(_storage) public view returns (uint) { UIntStorage counter = UIntStorage(_storage); require(counter.isUIntStorage()); return counter.getValue(); } function validateStorage(address _storage) public view returns (bool) { return UIntStorage(_storage).isUIntStorage(); } function transferStorage(address _storage, address _counter) onlyOwner validStorage(_storage) public returns (bool) { UIntStorage(_storage).transferOwnership(_counter); return true; } } 

We proceed to the implementation of the controller. Basic requirements for the controller:
1) Accounting for the current implementation of the counter
2) Update counter sales
2) Moving rights to the repository when updating the implementation
3) Rejection of unauthorized update implementation attempts


~/contracts/examples/counter/CounterContrller.sol :


Source url


 pragma solidity ^0.4.18; import "zeppelin-solidity/contracts/ownership/Ownable.sol"; import "./ICounter.sol"; import "../../base/UIntStorage.sol"; contract CounterController is Ownable { UIntStorage public store = new UIntStorage(); ICounter public counter; event CounterUpdate(address previousCounter, address nextCounter); function updateCounter(address _counter) onlyOwner public returns (bool) { if (address(counter) != 0x0) { counter.transferStorage(store, _counter); } else { store.transferOwnership(_counter); } CounterUpdate(counter, _counter); counter = ICounter(_counter); } function increaseCounter() public returns (uint) { return counter.increaseCounter(store); } function getCounter() public view returns (uint) { return counter.getCounter(store); } } 

increaseCounter and getCounter nothing more than just external methods of interaction with those similar to the current ICounter implementation. All controller logic is in a small method: updateCounter(address _counter) .


updateCounter method accept the address for the counter implementation and before installing it as the address of the new counter implementation? gives him rights to the repository (from himself or from the previous one depending on the state)


Remember the third stage? I will omit the code for its implementation, especially since it differs from the second only in one line. I’ll just say that in the third stage the counter will increase the value by multiplying by itself: value = value * value .


Let's write some tests and make sure that the controller works and performs the tasks assigned to it:


 import expectThrow from './utils/expectThrow' const IncrementCounter = artifacts.require('./IncrementCounter.sol') const IncrementCounterPhaseTwo = artifacts.require('./IncrementCounterPhaseTwo.sol') const MultiplyCounterPhaseThree = artifacts.require('./MultiplyCounterPhaseThree.sol') const CounterController = artifacts.require('./CounterController.sol') const UIntStorage = artifacts.require('./UIntStorage.sol') contract('CounterController', ([owner, user]) => { let controller, counterOne, counterTwo, counterThree, storage before(async () => { controller = await CounterController.new() storage = UIntStorage.at(await controller.store()) counterOne = await IncrementCounter.new() counterTwo = await IncrementCounterPhaseTwo.new() counterThree = await MultiplyCounterPhaseThree.new() await counterOne.transferOwnership(controller.address) await counterTwo.transferOwnership(controller.address) await counterThree.transferOwnership(controller.address) }) it('Shoult create storage', async () => { assert(await storage.isUIntStorage(), 'Controller doesn\'t create proper storage') }) it('Should change counter implementation', async () => { await controller.updateCounter(counterOne.address) assert(await controller.counter() === counterOne.address, `Unxpected counter in controller (${await controller.counter()} but expect ${counterOne.address})`) }) it('Should increase counter on 1', async () => { await controller.increaseCounter() const value = await controller.getCounter() assert(value.eq(1), `Unxpected counter value: ${value.toString(10)}`) }) it('Should update counter', async () => { await controller.updateCounter(counterTwo.address) assert(await controller.counter() === counterTwo.address, `Unxpected counter in controller (${await controller.counter()} but expect ${counterTwo.address})`) }) it('Should increase counter on 10 after update', async () => { await controller.increaseCounter() const value = await controller.getCounter() assert(value.eq(11), `Unxpected counter value: ${value.toString(10)}`) }) it('Should reject non-authenticated update', async () => { await expectThrow(controller.updateCounter(counterTwo.address, { from: user })) }) it('Should update on phase three and increase counter to 11*11 after execution', async () => { await controller.updateCounter(counterThree.address) await controller.increaseCounter() const value = await controller.getCounter() assert(value.eq(121), `Unxpected counter value: ${value.toString(10)}`) }) }) 

  Contract: CounterController βœ“ Shoult create storage βœ“ Should change counter implementation (53ms) βœ“ Should increase counter on 1 (52ms) βœ“ Should update counter (55ms) βœ“ Should increase counter on 10 after update (56ms) βœ“ Should reject non-authenticated update βœ“ Should update on phase three and increase counter to 11*11 after execution (89ms) 7 passing (684ms) 

As you can see the controller performs its task, and the code of our counter has become updated.


Summary


Despite the abstract (and absurdity) of the example, the approach can be applied in real contracts. For example, to ensure the upgradeability of game logic in the Evogame project, I use this approach in contracts that implement monster cards, battle logic, etc.


But this approach has a number of significant flaws and comments:



UPD:
@dzentota in the telegram discussion noted a flaw: an extra call to isUIntStorage() in the IncrementCounter methods. (Correction) [ https://github.com/alerdenisov/upgradable-contracts/blob/master/contracts/examples/counter/IncrementCounter.sol ]


')

Source: https://habr.com/ru/post/345606/


All Articles