本文讨论了使用状态机作为在Solidity(以太坊区块链的事实上的智能合约语言)中加强工作流程的便捷方法。
状态机只处于一种状态,通过改变输入或输入事件在状态之间移动。
对于Solidity的智能合同,消息将从其他帐户(外部帐户或其他合约)发送到合约功能以引起状态更改。如果输入对于当前状态有效,则状态机将移至新状态。
背景
在Datona Labs的Solidity智能数据访问合同(S-DAC)模板的开发和测试过程中,我们经常使用状态机来涵盖工作流程。本文的示例中,在两个提出问题并提供答案的各方之间。
UML状态机图
这是我们的示例的UML状态机图:
圆角矩形表示状态,箭头线表示转换,转换标签是导致转换发生的触发事件(来自指定源)。
Solidity状态机模型
状态机通过转换功能在解决方案合约中定义的状态之间移动。这是部分开发的Solidity合约:
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;
}
}
可以很容易地看到当前状态(通过功能修饰符onlyState进行检查以确保仅在正确的状态下执行状态转换),新状态和转换功能直接映射到UML状态机图上。
我们称拥有数据的一方为数据所有者。这可能与合约所有者不同。
对使用数据感兴趣的一方称为数据请求者。
继承的支持合约
由于这些角色是我们在Datona Labs合约中的常见主题,因此在合约开发过程中,我们可以将基类用于这些角色以及我们领域中其他常见的角色。为方便操作,此技术实际上是在劫持使用继承代替合成的方法。此代码仅测试使用,我们不建议将其用于生产代码。
DataOwner基类包含通用数据所有者操作,如构造函数和函数修饰符(onlyDataOwner),如下所示:
contract DataOwner {
address private dataOwner;
constructor(address account) public {
dataOwner = account;
}
modifier onlyDataOwner() {
require(msg.sender == dataOwner, "Must be Data Owner");
_;
}
...
}
可以提供其他函数,例如changeDataOwner(account)来帮助快速开发试用合约,但在这种情况下则不需要。
DataRequester和其他角色基础类相似。
我们可以将基本角色类中的功能修饰符添加到解决方案合约中:
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 ...
}
必须在解决方案合约构造函数期间初始化DataOwner和DataRequester基类。一方或双方的帐户(AKA地址)可以作为解决方案合同的参数提供。如果未提供该帐户(即其值为0),则使用msg.sender帐户(即合同所有者)。我们使用了getAccount函数来辅助上述构造函数的可读性。
DataOwner和DataRequester是同一个帐户是没有意义的,因此我们还必须在构造函数代码中测试此条件:
constructor(address dataOwner, address dataRequester)
DcDataOwner(getAccount(dataOwner))
DcDataRequester(getAccount(dataRequester)) public {
require(dataOwner != dataRequester, "Data Owner must not "
"be Data Requester");
}
在区块链上存储数据
我们需要在等待数据所有者提取问题时存储数据请求者的问题,并在等待数据请求者提取问题时存储数据所有者的答案。在此示例中,我们将问题和答案都存储在合约的公共数据字符串中:
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;
}
}
我们可以使用一个公共数据字符串,因为状态机确保在任何时候只需要使用一次字符串(问题或答案),并且我们鼓励我们尽量减少存储的使用,因为它太贵了-请看下面的内容了解它的成本。
分层状态机
当前解决方案的一个问题是,无法终止合约并将任何未付资金退还给合约所有人。
假定可以随时执行此操作,则该解决方案可以实现以下分层状态机设计(有关更多信息,请参见UML状态机):
合约所有者有权随时终止
这可以在我们的解决方案合约中轻松实现,方法是从ContractOwner基类继承(再次劫持继承作为合成的替代),该基类自动记录合约所有者并提供onlyContractOwner修饰符(类似于上述DataOwner的方式):
contract QnAsm100 is ContractOwner ... {
....
function terminate() public onlyContractOwner {
selfdestruct(msg.sender);
}
}
Solidity selfdestruct()函数通过终止函数将未偿还的以太币返回给给定帐户,合约所有者可以随时调用该函数。
合约测试
为了测试解决方案合约,我们可以将其部署在区块链上-一个测试网就可以了。但是解决方案合约构造函数有2个参数,DataOwner帐户和DataRequester帐户。
为了实现自动化测试,我们可以创建代理帐户来执行DataOwner和DataRequester的操作。以下是DataOwner代理帐户的示例:
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();
}
}
DataRequester代理合约相似。它提供了setQuestion和getAnswer函数。
测试合约本身创建代理帐户,然后创建解决方案合约:
import "StringLib.sol"; // equal etc
contract 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");
}
}
上面的示例提供了一个公共函数TestQnA,它通过proxyDataRequester将给定的问题发送到解决方案合约。然后它从proxyDataOwner中恢复问题并确认其有效。相同的操作发生在另一个方向,给定答案通过proxyDataOwner提供给解决方案合约,然后从proxyDataRequester中提取并进行检查。
建议进行其他测试,以确保状态机的行为符合预期结果。
气体消耗量
连续询问20个字符的问题并立即获得20个字符的答案所需的气体消耗量约为每个序列85,000个气体:
由于第一次在存储中分配了用于数据字符串的空间,因此第一次的耗气量更大。答案只是更新已经分配的数据字符串。
气体消耗量还取决于字符串的长度。
0、32、64字串问答序列耗气量
由于第一次在存储中分配了用于数据字符串的空间,因此第一次的耗气量更大。非空字符串比空字符串消耗的气体要多得多,因为必须分配字符串的地址以及实际的字符串字符。
使用事件的替代解决方案
如果您选择让解决方案合约在收到问题或答案时发出事件(请参阅Solidity事件),则可以简化状态机。例如:
但是事件只能由前端DApp代码处理,而不能由其他合约处理。这导致测试策略需要前端测试。解决方案合约还至少需要进行以下修改:
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;
}
...
}
状态机问题
状态机的最大问题既是其最大的资产,也是其最大的弱点,此示例对此进行了说明。必须按照设计进行工作流。可能有偏差。从设计上讲,它们是模态的。
在这种情况下,如果数据请求者希望提出第二个问题,则只有在得到第一个问题的答案之后,他们才能继续进行!这在实践中将非常笨拙,而且无法使用。这里有多种解决方法,例如通过部署多个合约(非常昂贵)或通过使用队列的问题和答案,但是真正的错误在于此特定状态机的基本设计。在这种情况下,一种出色的设计可能只是双向交互模式,其中问题和答案可以从一个角色自由地流向另一个角色。
结论
状态机是控制Solidity合约中工作流的理想方法。
状态机易于测试,在Solidity中与大多数其他语言一样,都是如此。
应该采用分层状态机设计来确保对例如终止状态机的适当控制。
如果合约是直接由生产使用的,则应精心设计“状态机”以避免形式化。
Solidity提供了有用的函数修饰符特性,非常适合在更改状态机状态之前清楚地执行检查当前状态和其他要求。
与存储数据的成本相比,状态机的气体消耗量很小。也许把数据存储在链外会更好…