State Machines in Solidity

This article discusses using State Machines as a convenient way of enforcing the workflow in Solidity, the defacto smart contract language for the Ethereum blockchain.

Photo by Franck V. on Unsplash

A State Machine is only ever in one state and is moved between states by changing inputs or by input events.

In the case of a Smart Contract in Solidity, a message is sent from a different account (an external account or another contract) to a contract function to cause a change in state. If the input is valid for the current state, the State Machine will move to a new state.

During the development and testing of Datona Labs’ Solidity Smart-Data-Access-Contract (S-DAC) templates, we often use state machines to encompass the workflow. In the example for this article, between two parties who ask questions and provide answers.

Roles and Actions in our example

UML State Machine Diagram

Here is the UML State Machine diagram for our example:

Example Question And Answer workflow between Data Owner and Data Requester

The round-cornered rectangles represent states, the arrowed lines are transitions and the transition labels are the trigger events (from the specified sources) that cause the transition to take place.

Solidity State Machine Model

The State Machine is moved between the states defined in the solution contract by the transition functions. Here is the partially developed Solidity contract:

contract QnAsm100 is ... { enum States { 
AwaitQuestion, GotQuestion, AwaitAnswer, GotAnswer }

States public state = States.AwaitQuestion;

...

modifier onlyState(States expected) {
require(state == expected, "Not permitted in this state");
_;
}

function setQuestion() public onlyState(States.AwaitQuestion) {
state = States.GotQuestion;
}

function getQuestion() public onlyState(States.GotQuestion) {
state = States.AwaitAnswer;
}

function setAnswer() public onlyState(States.AwaitAnswer) {
state = States.GotAnswer;
}

function getAnswer() public onlyState(States.GotAnswer) {
state = States.AwaitQuestion;
}
}

It can easily be seen that the current state (checked by the function modifier onlyState to ensure that state transitions are only performed in the correct state), the new states and the transition functions map directly onto the UML State Machine diagram.

We call the party who owns the data the Data Owner. This may be different to the contract owner.

The party interested in using the data is called the Data Requester.

Inherited Support Contracts

Since these roles are common themes in our contracts at Datona Labs, during contract development we can use base classes for these roles and each of the other roles common to our domain. This technique is actually hijacking the use of inheritance as a substitute for composition, for the convenience of the author. It may be acceptable for test code, but we don’t recommend it for production code.

The DataOwner base class encompasses generic data owner operations, such as a constructor, and a function modifier (onlyDataOwner) as illustrated below:

contract DataOwner { address private dataOwner;

constructor(address account) public {
dataOwner = account;
}

modifier onlyDataOwner() {
require(msg.sender == dataOwner, "Must be Data Owner");
_;
}

...
}

Other functions, such as changeDataOwner(account) may be provided to assist rapid development of trial contracts, but none are needed in this case.

The DataRequester and other role base classes are similar.

We can add the function modifiers from the base role classes to the solution contract:

contract QnAsm100 is DataOwner, DataRequester, ... { ...

function getAccount(address account) internal view returns
(address) {
return (account != address(0)) ? account : msg.sender;
}

constructor(address dataOwner, address dataRequester)
DcDataOwner(getAccount(dataOwner))
DcDataRequester(getAccount(dataRequester)) public {
...
}

function setQuestion() public isDataRequester ...

function getQuestion() public isDataOwner ...

function setAnswer() public isDataOwner ...

function getAnswer() public isDataRequester ...
}

The DataOwner and DataRequester base classes must be initialised during the solution contract constructor. The account (AKA address) for either party or both parties may be supplied as an argument to the solution contract. If the account is not supplied (that is, it has a value of 0), then the msg.sender account (i.e. the contract owner) is used. We have used the function getAccount to aid the readability of the constructor above.

It does not make sense for the DataOwner and DataRequester to be the same account, so we must also test for this condition within the constructor code:

 constructor(address dataOwner, address dataRequester)
DcDataOwner(getAccount(dataOwner))
DcDataRequester(getAccount(dataRequester)) public {
require(dataOwner != dataRequester, "Data Owner must not "
"be Data Requester");

}

Storing data on the blockchain

We need to store the Data Requester’s question while waiting for the Data Owner to fetch it, and the Data Owner’s answer while waiting for the Data Requester to fetch it. In this example, we store both the question and answer in a common data string in the contract:

contract QnAsm100 is ... { string data; ...

function setQuestion(string memory question) ... {
...
data = question;
}

function getQuestion() ... returns (string memory question) {
...
question = data;
}

function setAnswer(string memory answer) ... {
...
data = answer;
}

function getAnswer() ... returns (string memory answer) {
...
answer = data;
}
}

We can use a common data string because the State Machine ensures that only one use of the string (question or answer) is required at any one time, and we are encouraged to minimise the use of storage because it is so expensive — see below for just how expensive it is.

Hierarchical State Machine

One of the issues with the current solution is that there is no way to terminate the contract and release any outstanding funds back to the contract owner.

Assuming that this could be done at any time, the solution can implement the following Hierarchical State Machine design:

Contract Owner has power to terminate at any time

This is easily implemented in our solution contract by inheriting from the ContractOwner base class (hijacking inheritance as a substitute for composition again), which automatically records the contract owner and provides an onlyContractOwner modifier (in a similar manner to DataOwner, above):

contract QnAsm100 is ContractOwner ... {
....
function terminate() public onlyContractOwner {
selfdestruct(msg.sender);
}
}

The Solidity selfdestruct() function returns the outstanding ether to the given account via the terminate function which may be called by the contract owner at any time.

Testing by Contract

In order to test the solution contract, we can deploy it on a blockchain — a testnet is fine. However, the solution contract constructor has 2 parameters, the DataOwner account and the DataRequester account.

To assist automated testing, we can create proxy accounts which perform the actions of the DataOwner and the DataRequester. Here is an example of the DataOwner proxy account:

contract ProxyDataOwner { QnAsm100 qnaStateMachine; function setStateMachine(QnAsm100 _qnaStateMachine) public {
qnaStateMachine = _qnaStateMachine;
}

function setAnswer(string memory answer) public {
qnaStateMachine.setAnswer(answer);
}

function getQuestion() public returns (string memory question) {
return qnaStateMachine.getQuestion();
}
}

The DataRequester proxy contract is similar. It provides setQuestion and getAnswer functions.

The test contract itself creates the proxy accounts and then the solution contract:

import "StringLib.sol"; // equal etccontract TestQnAsm100 {
using StrlingLib for string;
ProxyDataOwner public proxyDataOwner = new ProxyDataOwner(); ProxyDataRequester public proxyDataRequester =
new ProxyDataRequester();

QnaStateMachine public qnaStateMachine = new
QnaStateMachine(address(proxyDataOwner),
address(proxyDataRequester));

constructor() public {
proxyDataOwner.setStateMachine(qnaStateMachine);
proxyDataRequester.setStateMachine(qnaStateMachine);
}

function testQnA(string memory question, string memory answer)
public {
// send and check question
proxyDataRequester.setQuestion(question);
string memory actual = proxyDataOwner.getQuestion();
require(question.equal(actual), "question not equal");

// send and check answer
proxyDataOwner.setAnswer(answer);
actual = proxyDataRequester.getAnswer();
require(answer.equal(actual), "answer not equal");
}
}

The example above provides a public function TestQnA which sends the given question via the proxyDataRequester to the solution contract. Then it recovers the question from the proxyDataOwner and confirms that it is valid. The same operation takes place in the other direction with the given answer being supplied to the solution contract via the proxyDataOwner, then being extracted from the proxyDataRequester and checked.

Other tests are recommended to ensure that the State Machine behaves as expected.

Gas Consumption

The gas consumption to continually ask a 20 character question and immediately get a 20 character answer is approximately 85,000 gas per sequence:

Gas consumption for 20 character string question and answer sequence

The gas consumption is greater the first time because space for the data string is allocated in storage. The answer simply updates the already allocated data string.

The gas consumption also depends on the length of the string.

Gas consumption for 0, 32 and 64 character string question and answer sequence

The gas consumption is greater the first time because space for the data string is allocated in storage. A non-empty string consumes far more gas than an empty string, because the address of the string has to be allocated as well as the actual string characters.

If you elect for your solution contract to emit events whenever it receives a question or answer, then the State Machine can be simplified. For example:

However, the events can only be handled by the frontside DApp code — not by another contract. This causes the testing strategy to require frontside testing. The solution contract will also require at least these modifications:

contract QnAsm100 ... { ... event Question(
address indexed _from,
address indexed _to,
bytes32 _value
);
... function setQuestion(string memory question) public
onlyDataRequester onlyState(States.AwaitQuestion) {
reportSet(question);

emit Question(msg.sender, dataOwner(), bytes32of(question));

state = States.AwaitAnswer;
}

...
}

State Machine Issues

The biggest issue with State Machines is both their greatest asset and their greatest weakness and is illustrated by this example. The workflow must be followed as designed. No deviation is possible. They are modal, by design.

In this case, if the Data Requester wishes to ask a second question, they cannot proceed until they have the answer to the first question! This would be very clunky in practice and on the edge of unusable. There are various ways to solve that here, for example by deploying multiple contracts (very expensive) or by employing queues of questions and answers, but the real fault here lies in the fundamental design of this particular State Machine. A superior design in this case may simply be a bi-directional interactive mode where questions and answers can flow freely from one role to the other.

State Machines are an ideal way to control workflow in Solidity contracts.

State Machines are easy to test and that is just as true in Solidity as in most other languages.

Hierarchical State Machine design should be employed to ensure adequate control of, for instance, terminating the State Machine.

If the contracts are used directly by people, the State Machines should be carefully designed to avoid modality.

Solidity provides the useful function modifier feature which is ideal for clearly implementing checking of the current state and other requirements before changing the state of the State Machine.

The gas consumption of the State Machine is small compared with the cost of storing data.

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

来源

What do you think?

发表评论

电子邮件地址不会被公开。 必填项已用*标注

Loading…

0

Comments

0 comments

Protocols for Loanable Funds: A First Dive into DeFi Lending and Borrowing

Compound Governance Proposal 11: COMP Distribution Patch