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
The implementation of the required functionality - updating the code, is planned through the separation of the code into its components:
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.
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
:
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
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
:
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
:
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
:
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.
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
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:
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:
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"
Before implementing the common controller, we will make another counter contract, but already the second stage - ~/contracts/examples/counter/IncrementCounterPhaseTwo.sol
:
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
:
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:
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:
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
:
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.
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:
delegatecall
. Write me if you need to write a continuation with the transfer of state via delegatecall
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