Agreeable Smart Contracts

Who decides whether to terminate a smart contract? Who decides whether a smart contract should change state?

The answer, of course, is that who decides these things is encoded into the smart contract itself, and it will be different for different contracts.

For example, it may be only the smart contract owner who may terminate the contract. Alternatively, maybe the smart contract gives that ability to the two smart contract users who must agree amongst themselves. Or to 3 of the 4 smart contract users who must agree amongst themselves. There are endless possibilities.

We can make this easier, by providing our smart contracts with agreement objects, that ensure that the smart contract enforces those mult-signature agreements between users before doing whatever has been agreed, changing state or maybe even changing the smart contract itself. The choice is endless. For instance, here is an article by this author which describes changing a smart contract following agreement between the smart contract owner and the two users.

A real-world example could be a smart contract that handles last will and testament. In this case, there may be various phases of generating the document, perhaps by some of many solicitors, followed by an approval phase comprising some solicitors and some family members, followed by an execution phase comprising, again, some solicitors and the executors. There may be other phases, each of which comprises various numbers of users in different roles.

Also Read: Proposing Future Ethereum Access Control

Example

For this article, we will transition an example smart contract through each of several states, but at each transition only when an agreement between users has been reached.

In this example smart contract, there are 4 users.

The first transition requires the first 2 of the users to agree to something before taking place. 

The next transition requires the last 3 of our 4 smart contract users to agree before the smart contract can transition to the penultimate state.

In the penultimate state, we require any 3 out of the 4 users to agree in order to facilitate the transition to the terminated state. 

Here is a UML State Machine diagram illustrating the states and transitions:

UML state machine diagram for example smart contract
UML state machine diagram for example smart contract

See this article by the same author for a discussion of State Machines in Solidity.

The actions of each of the 4 user must be encoded into the smart contract.

User    Actions
user1   transition from Await12 to Await234
possibly transition from Await3of4 to Terminated
user2   transition from Await12 to Await234
transition from Await234 to Await3of4
possibly transition from Await3of4 to Terminated
user3   transition from Await234 to Await3of4
possibly transition from Await3of4 to Terminated
user4   transition from Await234 to Await3of4
possibly transition from Await3of4 to Terminated

Only 3 of the 4 users are required to agree for the ultimate transition.

Solution Options

Amongst the many possible solution options, we will consider these options for implementing the agreement objects:

1) Smart Contract using Accounts
2) Smart Contract using Roles
3) Using Library For Struct and Accounts
4) Using Library For Struct and Roles

Smart Contracts are not ideal for implementing objects in Solidity, because they carry a large overhead — see below.

Using Library For Struct is the main feature provided by Solidity to implement objects.

There is a third option, which is using inheritance as a substitution for composition, but because the example smart contract has multiple agreement objects, that is not applicable here.

This article by the the same author discusses class features provided by Solidity in more detail.

The Accounts/Roles options are considered because the implementation could use roles instead of accounts — that decision typically depends on whether your smart contract has more than one user performing the same role. There is plenty of information about role-based access control available. See this helpful OpenZeppelin contribution for instance.

In practice, we only need to implement 3 of the 4 options and deduce whether it is worth implementing the 4th option. We will not bother with option (2) unless we encounter a compelling reason to implement it.

Smart Contracts

Here is the partially completed example smart contract, which fulfils the UML state machine design above, and provides logical class objects for subsequent implementation:

                    MultipleAgreementContract
contract MultipleAgreementContract {

// provide agreement objects
... agreement12;
... agreement234;
... agreement3of4;

constructor(address[4] memory accounts) public {
// initialise all agreement objects
}
    enum State { Await12, Await234, Await3of4, Terminated }
State public state = State.Await12;
    function transition12(address account) public {
require(state == State.Await12);
uint object = 123;
if (agreement12.agree(object, account) > 0)
state = State.Await234;
}
    function transition234(address account) public returns (bool) {
require(state == State.Await234);
uint object = 456;
if (agreement234.agree(object, account) > 0)
state = State.Await3of4;
}
    function transition3of4(address account) public returns (bool) {
require(state == State.Await3of4);
uint object = 789;
if (agreement3of4.agree(object, account) > 0)
state = State.Terminated;
}
}

This smart contract contains the state machine. This is simply implemented in the smart contract using enum State, require(state == X) and state = State.X. The state is made public in order to assist with testing (shown later).

The smart contract contains the transition functions that must be invoked by the users in order to attempt transition to the next state.

The state changing logic of the transition functions will only be executed if the function is called in the correct state. Each transition function must be called by each of the users who are responsible for the transition, but in any order. For instance, in order to transition from State.Await12 to State.Await234, both user 1 and user 2 must invoke the transition functiontransition12. Following the second such invocation, the machine state will be changed.

The different solution options are discussed further in the following sections.

1) Smart Contract using Accounts

Here is the MultipleAgreementContract constructor for this solution option:

    MultipleAgreementContract for Smart Contract using Accounts
import "Agreement2.sol";
import "Agreement3.sol";
import "Agreement3of4.sol";
contract MultipleAgreementContract {

Agreement2 agreement12;
Agreement3 agreement234;
Agreement3of4 agreement3of4;
    constructor( address[4] memory accounts ) public {
agreement12 = new Agreement2([accounts[0], accounts[1]]);
agreement234 = new Agreement3(
[accounts[1], accounts[2], accounts[3]]);
agreement3of4 = new Agreement3of4(accounts);
}

Here, each of the agreement objects has its own smart contract and is instantiated with the user accounts that are required to achieve agreement.

The implementation contracts are along these lines:

                             Agreement2
// agreement between 2 accounts
import "AgreementBase.sol";
contract Agreement2 is AgreementBase {
constructor( address[2] memory _accounts ) public {
accounts.push(_accounts[0]);
accounts.push(_accounts[1]);
sigs = 2;
}
}

The implementations of Agreement3 and Agreement3of4 are similar. The AgreementBase contract that these implementation contracts require is:

                            AgreementBase
// agreement between configured accounts
import "DiBits.sol";
contract AgreementBase {

address[] accounts; // set accounts in derived class
uint sigs; // // set minimum signatures in derived class
    uint object;
uint bitMasks;

function agree(uint _object, address account) public returns
(int) {
        // returns <0:new object, 0:no change, 1:agreed object
        uint bitMask;
for (uint i = 0; i < accounts.length; i++) {
if (account == accounts[i]) {
bitMask = 1 << i;
break;
}
}
require(bitMask != 0, "Unknown sender");

if (object != _object) {
object = _object;
bitMasks = bitMask;
return -1;
}

bitMasks |= bitMask;

if (DiBits.count(bitMasks) >= sigs) {
return 1;
}
}
}

The accounts array and sigs value must be configured in derived contracts.

The sigs variable is used for the Agreement3of4 class, and generally for mutli-sig agreements requiring agreement amongst some of the users. This feature of the agreement objects enables the smart contract to determine when agreement has been reached. That could be 1 of 2, 1 of 5, or 3 of 4 as in this case.

Events may be usefully emitted when a new object is detected, and when agreement is reached. This is not shown for clarity.

The library function DiBits.count(uint) simply counts the number of set bits.

3) Using Library For Struct and Accounts

Here is the MultipleAgreementContract constructor for this solution option:

MultipleAgreementContract for Using Library For Struct and Accounts
import "Agreement.sol";
contract MultipleAgreementContract {
using AgreementFuns for Agreement;

Agreement agreement12;
Agreement agreement234;
Agreement agreement3of4;

constructor(address[4] memory accounts) public {
agreement12.initialise([accounts[0], accounts[1]]);
agreement234.initialise(
[accounts[1], accounts[2], accounts[3]]);
agreement3of4.initialise(accounts);
agreement3of4.sigs = 3;
}

The line using AgreementFuns for Agreement, causes the compiler to use the functions in the AgreementFuns library for Agreement objects. See the Solidity documentation here for more information.

The constructor initialises the 3 agreement objects with the user accounts required to fulfil the agreements. The last line resets the minimum number of accounts that must agree to 3 (otherwise it would have been all 4 accounts).

Here is the agreement inclusion file:

                   Agreement using address accounts
// agreement between given accounts
import "DiBits.sol";
struct Agreement {
address[] accounts; // user accounts are recorded here
uint sigs; // the min number of accounts required to agree

uint object; // the object to be agreed e.g. a contract address
uint bitMasks; // the set of agreed accounts
}
// -----------
library AgreementFuns {

function initialise(Agreement storage self, address[2] memory
accounts) public {
self.accounts.push(accounts[0]);
self.accounts.push(accounts[1]);
self.sigs = 2;
}

// also initialisers for address[3 and 4] memory accounts

function agree(Agreement storage self, uint _object, address
sender) public returns (int) {
        // returns <0:new object, 0:no change, 1:agreed object
        uint bitMask;
uint length = self.accounts.length;
for (uint i = 0; i < length; i++) {
if (sender == self.accounts[i]) {
bitMask = 1 << i;
break;
}
}
require(bitMask != 0, "Unknown sender");

if (self.object != _object) {
self.object = _object;
self.bitMasks = bitMask;
return -1;
}

self.bitMasks |= bitMask;

if (DiBits.count(self.bitMasks) >= self.sigs) {
return 1;
}
}
}

This is substantially the same as presented above. The differences are in bold.

As stated above, events may be usefully emitted when a new object is detected, and when agreement is reached. This is not shown for clarity.

When using smart contracts, we can derive new contracts from base contracts at no cost to the code.

However, when using libraries, we are a little more restricted because libraries cannot have base classes or be base classes. So all possible initialisers have to be added to the library to retain data encapsulation. For this reason, it may be preferable to use a dynamic array in the initialiser, and avoid multiple initialisers. That effectively moves the configuration of the Agreement accounts data to the calling routine. Hence, we haven’t bothered with that here.

4) Using Library For Struct and Roles

Here is the MultipleAgreementContract constructor for this solution option:

MultipleAgreementContract for Using Library For Struct and Roles
import "Agreement.sol";
contract MultipleAgreementContract {
using AgreementFuns for Agreement;

Agreement agreement12;
Agreement agreement234;
Agreement agreement3of4;

mapping(address => uint8) roles;
    constructor(address[4] memory accounts) public {
roles[accounts[0]] = 1;
roles[accounts[1]] = 2;
roles[accounts[2]] = 3;
roles[accounts[3]] = 4;

agreement12.initialise([1, 2]);
agreement234.initialise([2, 3, 4]);
agreement3of4.initialise([1, 2, 3, 4]);
agreement3of4.sigs = 3; // multi-sig 3 of 4
}

This is substantially the same as the previous solution option, except for the addition of a mapping from the account addresses to the roles identifiers, and initialisation of same in the constructor — we have just used the Role numbers 1 to 4. Lastly, we pass the role numbers, not the account addresses, through to the agreement objects.

This facility means that, if needed, we can easily add user accounts which map to the current roles without changing anything else in the smart contract, for instance:

              Example Additional User for existing Role
    constructor(address[5] memory accounts) public {
roles[accounts[0]] = 1;
roles[accounts[1]] = 2;
roles[accounts[2]] = 3;
roles[accounts[3]] = 4;
        roles[accounts[4]] = 2; // additional account for role 2

agreement12.initialise([1, 2]);
agreement234.initialise([2, 3, 4]);
agreement3of4.initialise([1, 2, 3, 4]);
agreement3of4.sigs = 3; // multi-sig 3 of 4
}

The agreement inclusion file is substantially the same as the previous example. We simply replace all account addresses with role uints:

                   4) Agreement using uint roles
// agreement between roles
struct Agreement {
uint[] roles; // roles are recorded here
    // as before
}

That also means that there is an opportunity here to use a variable which requires less than 32bytes to record a role. As well as using an array of uints to contain the roles, we will try using a bytes32 variable, with 1 byte allocated to each role:

                  4a) Agreement using bytes32 roles
struct Agreement {
uint len; // number of roles
bytes32 roles; // roles are recorded here
uint sigs; // the min number of roles required to agree

uint bitMasks;
uint object;
}

Obviously, the initialisation and agreement functions will need a little fettling.

Lastly, we would like to take the opportunity to optimise the gas consumption by minimising the use of Ethereum’s expensive storage slots:

           4b) Agreement optimising use of storage space
struct Agreement {
uint160 object; // large enough to hold address
uint64 roles; // up to 16 roles of 16 values each
uint8 len; // number of roles
uint8 sigs; // // the min number of roles required to agree
uint8 bitMasks; // set of agreements reached so far
}

This whole struct fits within one 256bit storage word. Despite the additional machinations required to read and write the data, the gas consumption for storage access will be vastly reduced.

Testing

Photo by Science in HD on Unsplash
Photo by Science in HD on Unsplash

The fundamental approach to testing used here is to step the smart contract under test through the machine states originally specified (sunny day testing). Other tests are required to ensure that the smart contract is working properly. The test smart contract is:

                     TestMultipleAgreementContract
contract TestMultipleAgreementContract {
address constant account1 = 0x...1111111111;
address constant account2 = 0x...2222222222;
address constant account3 = 0x...3333333333;
address constant account4 = 0x...4444444444;
    MultipleAgreementContract mac;

constructor() public {
mac = new MultipleAgreementContract(
[account1, account2, account3, account4]);
}

function test12() public {
require(mac.state() == ...Await12);
mac.transit12(account1);
require(mac.state() == ...Await12);
mac.transit12(account2);
require(mac.state() == ...Await234);
}

function test234() public {
require(mac.state() == ...Await234);
mac.transit234(account2);
require(mac.state() == ...Await234);
mac.transit234(account3);
require(mac.state() == ...Await234);
mac.transit234(account4);
require(mac.state() == ...Await3of4);
}

function test3of4() public {
require(mac.state() == ...Await3of4);
mac.transit3of4(account1);
require(mac.state() == ...Await3of4);
mac.transit3of4(account2);
require(mac.state() == ...Await3of4);
mac.transit3of4(account2);
require(mac.state() == ...Await3of4);
mac.transit3of4(account4);
require(mac.state() == ...Terminated);
}
}

The test contract is the same, irrespective of the smart contract being tested.

Gas Consumption

Having ascertained that all the solution contracts pass these simple tests, we can measure the gas consumed while doing so.

The gas consumption measuring smart contract to do that is along these lines:

                 GasTestMultipleAgreementContract
import "DcGas.sol";
contract GasTestMultipleAgreementContract is DcGas {
TestMultipleAgreementContract testMac;
    function testConstruct() internal {
testMac = new TestMultipleAgreementContract();
}
function gtestConstruct() public {
gasCostFun("testConstruct", testConstruct);
}
    function testTransit12() internal {
testMac.test12();
}
function gtestTransit12() public {
gasCostFun("testTransit12", testTransit12);
}
    function testTransit234() internal {
testMultipleAgreementContract.test234();
}
function gtestTransit234() public {
gasCostFun("testTransit234", testTransit234);
}
    function testTransit3of4() internal {
testMultipleAgreementContract.test3of4();
}
function gtestTransit3of4() public {
gasCostFun("testTransit3of4", testTransit3of4);
}
}

We used a method of measuring gas consumption we have used many times before. See this article by the same author for the tehnique.

Results

The gas consumed during execution of the tests was collated and entered into spreadsheets. Here is the total gas consumption for the solution options (1), (3) and (4), and the additional solutions introduced above, (4a) and (4b).

Gas consumption of executing the test smart contracts
Gas consumption of executing the test smart contracts

This chart clearly shows the high gas cost of using smart contracts to represent objects in the first column 1) Contract/Accounts.

Gas consumption is lower when employing using library for struct, as shown in 3) Library / Accounts.

Changing from using accounts with our libraries to using roles does consume more gas, as indicated by 4) Library / Roles.

However, we can mitigate that by employing smaller variables to hold our role number as shown in 4a) Library / Roles / bytes32.

Lastly, it can clearly be seen that optimising the Agreement struct to use bit fields in order to reduce the usage of expensive storage has a marked affect, as indicated by 4b) Library / Roles / Optimisation.

Assuming that the smart contract is long lived, and could transit from the ultimate state back to the initial state many times, the gas consumed during smart contract construction may be less relevant. Here is a graph of gas consumption ignore the construction costs:

Gas consumption of transitioning the test smart contracts
Gas consumption of transitioning the test smart contracts

This shows a lot more clearly that the optimised data structure for roles consumes vastly less gas than the other options.

Conclusions

Use Agreement objects whenever you need several users to agree on the same object before changing smart contract state.

Unless you have a good reason, avoid the use of contracts to represent objects and employ using library for struct instead.

If it suits the requirements of your smart contract, use agreement roles in preference to agreement accounts.

As well as additional flexibility in terms of enabling multiple users to occupy the same role, there is an opportunity to optimise the agreement role data and vastly reduce use of Ethereum’s expensive storage slots.

Bio

Jules Goddard is Co-founder of Datona Labs, who provide smart contracts to protect your digital information from abuse.

website — twitter — discord — GitHub

Newsletter

Get Best Crypto Deals

Leave a Reply