安全考虑
在编写智能合约时,安全是最重要的考虑因素之一。与其他程序一样,智能合约将完全按写入的内容执行,这并不总是程序员所期望的。此外,所有智能合约都是公开的,任何用户都可以通过创建交易来与他们进行交互。任何漏洞都可以被利用,损失几乎总是无法恢复。
在智能合约编程领域,错误代价高昂且容易被利用。因此,遵循最佳实践并使用经过良好测试的设计模式至关重要。
防御性编程 _Defensive programming_是一种编程风格,特别适用于智能合约编程,具有以下特点:
极简/简约
复杂性是安全的敌人。代码越简单,代码越少,发生错误或无法预料的效果的可能性就越小。当第一次参与智能合约编程时,开发人员试图编写大量代码。相反,你应该仔细查看你的智能合约代码,并尝试找到更少的方法,使用更少的代码行,更少的复杂性和更少的“功能”。如果有人告诉你他们的项目产生了“数千行代码”,那么你应该质疑该项目的安全性。更简单更安全。
代码重用
尽可能不要“重新发明轮子”。如果库或合约已经存在,可以满足你的大部分需求,请重新使用它。在你自己的代码中,遵循DRY原则:不要重复自己。如果你看到任何代码片段重复多次,请问自己是否可以将其作为函数或库进行编写并重新使用。已被广泛使用和测试的代码可能比你编写的任何新代码更安全。谨防“Not-Invented-Here”的态度,如果你试图通过从头开始构建“改进”某个功能或组件。安全风险通常大于改进值。
代码质量
智能合约代码是无情的。每个错误都可能导致经济损失。你不应该像通用编程一样对待智能合约编程。相反,你应该采用严谨的工程和软件开发方法论,类似于航空航天工程或类似的不容乐观的工程学科。一旦你“启动”你的代码,你就无法解决任何问题。
可读性/可审核性
你的代码应易于理解和清晰。阅读越容易,审计越容易。智能合约是公开的,因为任何人都可以对字节码进行逆向工程。因此,你应该使用协作和开源方法在公开场合开发你的工作。你应该编写文档良好,易于阅读的代码,遵循作为以太坊社区一部分的样式约定和命名约定。
测试覆盖
测试你可以测试的所有内容。智能合约运行在公共执行环境中,任何人都可以用他们想要的任何输入执行它们。你绝不应该假定输入(比如函数参数)是正确的,并且有一个良性的目的。测试所有参数以确保它们在预期的范围内并且格式正确。
常见的安全风险
智能合约程序员应该熟悉许多最常见的安全风险,以便能够检测和避免使他们面临这些风险的编程模式。
重入 Re-entrancy
重入是编程中的一种现象,函数或程序被中断,然后在先前调用完成之前再次调用。在智能合约编程的情况下,当合约A调用合约B中的一个函数时,可能会发生重入,合约B又调用合约A中的相同函数,导致递归执行。在合约状态在关键性调用结束之后才更新的情况下,这可能是特别危险的。
为了理解这一点,想象一下通过钱包合约调用银行合约的提现操作。合约A在合约B中调用提现功能,试图提取金额X。这种情况将涉及以下操作:
合约B检查A是否有必要的余额来提取X。
B将X传送到A的地址(运行A的payable fallback函数)
B更新A的余额以反映此次提现
无论何时向合约发送付款(如本例中),接收方合约(A)都有机会执行_payable_函数,例如默认的fallback函数。但是,恶意攻击者可以利用这种执行。想象一下,在A的payable fallback中,合约A_再次_调用B银行的提款功能。B的提现功能现在将经历重入,因为现在相同的初始交易正在引发循环调用。
“(1) A 调用 B (2) B 调用 A 的 payable 函数 (1) A 再次调用 B “
在B的退出提现函数的第二次迭代中,B将再次检查A是否有可用余额。由于步骤3(其更新了A的余额)尚未执行,所以对于B来说,无论该函数被重新调用多少次,A仍然具有可用资金来提现。只要有gas可以继续运行,就可以重复该循环。当A检测到gas量不足时,它可以在payable函数中停止呼叫B. B将最终执行步骤3,从A的余额中扣除X. 然而,这时,B可能已经执行了数百次转账,并且只扣除了一次费用。在这次袭击中,A有效地洗劫了B的资金。
这个漏洞因其与DAO攻击的相关性而特别出名。用户利用了这样一个事实,即在调用转移并提取价值数百万美元的ether后,合约中的余额才发生变化。
为了防止重入,最好的做法是让程序员使用_Checks-Effects-Interactions_模式,在进行调用之前应用函数调用的影响(例如减少余额)。在我们的例子中,这意味着切换步骤3和2:在传输之前更新用户的余额。
在以太坊,这是完全没问题的,因为交易的所有影响都是原子的,这意味着在没有支付给用户的情况下更新余额是不可能的。要么都发生,要么抛出异常,都不会发生。这样可以防止重入攻击,因为所有后续调用原始提现函数的操作都会遇到正确的修改后余额。通过切换这两个步骤,可以防止A的提现金额超过其余额。
设计模式
任何编程范式的软件开发人员通常都会遇到以行为,结构,交互和创建为主题的重复设计挑战。通常这些问题可以概括并重新应用于未来类似性质的问题。当给定正式结构时,这些概括称为*设计模式*。智能合约有自己的一系列重复出现的设计问题,可以使用下面描述的一些模式来解决。
在智能合约的发展中存在着无数的设计问题,因此无法讨论所有这些问题 这里。因此,本节将重点讨论智能合约设计中最常见的三类问题分类:访问控制(access control),状态流(state flow)*和*资金支出(fund disbursement)。
在本节中,我们将制定一份合约,最终将包含所有这三种设计模式。该合约将运行投票系统,允许用户对“真相”进行投票。该合约将提出一项声明,例如“小熊队赢得世界系列赛”。或者“纽约市正在下雨”,然后用户会有机会选择真或假。如果大多数参与者投票赞成”真”合约就认为该声明为真,如果大多数参与者投票赞成“假”,则合约将认为该声明为“假”。为了激励真实性,每次投票必须向合约发送100 ether,而失败的少数派出的资金将分给大多数。大多数参与者将从少数人中获得他们的部分奖金以及他们的初始投资。
这个“真相投票”系统实际上是Gnosis的基础,Gnosis是一个建立在以太坊之上的预测工具。有关Gnosis的更多信息,请访问:https://gnosis.pm/
访问控制 Access control
访问控制限制哪些用户可以调用合约功能。例如,真相投票合约的所有者可能决定限制那些可以参与投票的人。 为了达到这个目标,合约必须施加两个访问限制:
只有合约的所有者可以将新用户添加到“允许的选民”列表中
只有允许的选民可以投票
Solidity函数修饰器提供了一个简洁的方式来实现这些限制。
_Note: 以下示例在修改器主体内使用下划线分号。这是Solidity的功能,用于告知编译器何时运行被修饰的函数的主体。开发人员可以认为被修饰的函数的主体将被复制到下划线的位置。
pragma solidity ^0.4.21;
contract TruthVote {
address public owner = msg.sender;
address[] true_votes;
address[] false_votes;
mapping (address => bool) voters;
mapping (address => bool) hasVoted;
uint VOTE_COST = 100;
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
modifier onlyVoter() {
require(voters[msg.sender] != false);
_;
}
modifier hasNotVoted() {
require(hasVoted[msg.sender] == false);
_;
}
function addVoter(address voter)
public
onlyOwner()
{
voters[voter] = true;
}
function vote(bool val)
public
payable
onlyVoter()
hasNotVoted()
{
if (msg.value >= VOTE_COST) {
if (val) {
true_votes.push(msg.sender);
} else {
false_votes.push(msg.sender);
}
hasVoted[msg.sender] = true;
}
}
}
修饰器和函数的说明:
onlyOwner: 这个修饰器可以修饰一个函数,使得函数只能被地址与*owner*相同的发送者调用。
onlyVoter: 这个修饰器可以修饰一个函数,使得函数只能被已登记的选举人调用。
addVoter(voter): 此函数用于将选民添加到选民列表。该功能使用*onlyOwner*修饰器,因此只有该合约的所有者可以调用它。
vote(val): 这个函数被投票者用来对所提出的命题投下真或假。它用*onlyVoter*修饰器装饰,所以只有已登记的选民可以调用它。
状态流 State flow
许多合约将需要一些操作状态的概念。合约的状态将决定合约的行为方式以及在给定的时间点提供的操作。让我们回到我们的真实投票系统来获得更具体的例子。
我们投票系统的运作可以分为三个不同的状态。
Register: 服务已创建,所有者现在可以添加选民。
Vote: 所有选民投票。
Disperse: 投票付款被分给大多数参与者。
以下代码继续建立在访问控制代码的基础上,但进一步将功能限制在特定状态。 在Solidity中,使用枚举值来表示状态是司空见惯的事情。
pragma solidity ^0.4.21;
contract TruthVote {
enum States {
REGISTER,
VOTE,
DISPERSE
}
address public owner = msg.sender;
uint voteCost;
address[] trueVotes;
address[] falseVotes;
mapping (address => bool) voters;
mapping (address => bool) hasVoted;
uint VOTE_COST = 100;
States state;
modifier onlyOwner() {
require(msg.sender == owner);
_;
}
modifier onlyVoter() {
require(voters[msg.sender] != false);
_;
}
modifier isCurrentState(States _stage) {
require(state == _stage);
_;
}
modifier hasNotVoted() {
require(hasVoted[msg.sender] == false);
_;
}
function startVote()
public
onlyOwner()
isCurrentState(States.REGISTER)
{
goToNextState();
}
function goToNextState() internal {
state = States(uint(state) + 1);
}
modifier pretransition() {
goToNextState();
_;
}
function addVoter(address voter)
public
onlyOwner()
isCurrentState(States.REGISTER)
{
voters[voter] = true;
}
function vote(bool val)
public
payable
isCurrentState(States.VOTE)
onlyVoter()
hasNotVoted()
{
if (msg.value >= VOTE_COST) {
if (val) {
trueVotes.push(msg.sender);
} else {
falseVotes.push(msg.sender);
}
hasVoted[msg.sender] = true;
}
}
function disperse(bool val)
public
onlyOwner()
isCurrentState(States.VOTE)
pretransition()
{
address[] memory winningGroup;
uint winningCompensation;
if (trueVotes.length > falseVotes.length) {
winningGroup = trueVotes;
winningCompensation = VOTE_COST + (VOTE_COST*falseVotes.length) / trueVotes.length;
} else if (trueVotes.length < falseVotes.length) {
winningGroup = falseVotes;
winningCompensation = VOTE_COST + (VOTE_COST*trueVotes.length) / falseVotes.length;
} else {
winningGroup = trueVotes;
winningCompensation = VOTE_COST;
for (uint i = 0; i < falseVotes.length; i++) {
falseVotes[i].transfer(winningCompensation);
}
}
for (uint j = 0; j < winningGroup.length; j++) {
winningGroup[j].transfer(winningCompensation);
}
}
}
修饰器和函数的说明:
isCurrentState: 在继续执行装饰函数之前,此修饰器将要求合约处于指定状态。
pretransition: 在执行装饰函数的其余部分之前,此修饰器将转换到下一个状态
goToNextState: 将合约转换到下一个状态的函数
disperse: 计算大多数以及相应的瓜分奖金的功能。只有owner可以调用这个函数来正式结束投票。
startVote: 所有者可用于开始投票的功能。
注意到允许所有者随意关闭投票流程可能会导致合约的滥用很重要。在更真实的实现中,投票期应在公众理解的时间段后结束。对于这个例子,这没问题。
现在增加的内容确保只有在owner决定开始投票阶段时才允许投票,用户只能在投票前由owner注册,并且在投票结束后才能分配资金。
提现 Withdraw
许多合约将为用户从中提取资金提供一些方法。在我们的示例中,属于大多数的用户在合约开始分配资金时直接接收资金。虽然这看起来有效,但它是一种欠考虑的解决方案。在*disperse*中*addr.send()调用的接收地址可以是一个合约,具有一个会失败的fallback函数,会打断*disperse。这有效地阻止了更多的参与者接收他们的收入。 一个更好的解决方案是提供一个用户可以调用来收取收入的提款功能。
...
enum States {
REGISTER,
VOTE,
DETERMINE,
WITHDRAW
}
mapping (address => bool) votes;
uint trueCount;
uint falseCount;
bool winner;
uint winningCompensation;
modifier posttransition() {
_;
goToNextState();
}
function vote(bool val)
public
onlyVoter()
isCurrentStage(State.VOTE)
{
if (votes[msg.sender] == address(0) && msg.value >= VOTE_COST) {
votes[msg.sender] = val;
if (val) {
trueCount++;
} else {
falseCount++;
}
}
}
function determine(bool val)
public
onlyOwner()
isCurrentState(State.VOTE)
pretransition()
posttransition()
{
if (trueCount > falseCount) {
winner = true;
winningCompensation = VOTE_COST + (VOTE_COST*false_votes.length) / true_votes.length;
} else if (falseCount > trueCount) {
winner = false;
winningCompensation = VOTE_COST + (VOTE_COST*true_votes.length) / false_votes.length;
} else {
winningCompensation = VOTE_COST;
}
}
function withdraw()
public
onlyVoter()
isCurrentState(State.WITHDRAW)
{
if (votes[msg.sender] != address(0)) {
if (votes[msg.sender] == winner) {
msg.sender.transfer(winningCompensation);
}
}
}
...
修饰器和(更新)功能的说明:
posttransition: 函数调用后转换到下一个状态。
determine: 此功能与以前的*disperse*功能非常相似,除了现在只计算赢家和获胜赔偿金额,实际上并未发送任何资金。
vote: 投票现在被添加到votes mapping,并使用真/假计数器。
withdraw: 允许投票者提取胜利果实(如果有)。
这样,如果发送失败,则只在一个特定的调用者上失败,不影响其他用户提取他们的胜利果实。
合约库
Github link: https://github.com/ethpm
Repository link: https://www.ethpm.com/registry
Website: https://www.ethpm.com/
Documentation: https://www.ethpm.com/docs/integration-guide
安全最佳实践
Github: https://github.com/ConsenSys/smart-contract-best-practices/
Docs: https://consensys.github.io/smart-contract-best-practices/
https://blog.zeppelin.solutions/onward-with-ethereum-smart-contract-security-97a827e47702
也许最基本的软件安全原则是最大限度地重用可信代码。在区块链技术中,这甚至会凝结成一句格言:“Do not roll your own crypto”。就智能合约而言,这意味着尽可能多地从经社区彻底审查的免费库中获益。
在Ethereum中,使用最广泛的解决方案是https://openzeppelin.org/[OpenZeppelin]套件,从ERC20和ERC721的Token实现,到众多众包模型,到常见于“Ownable”,“Pausable”或“LimitBalance”等合约中的简单行为。该存储库中的合约已经过广泛的测试,并且在某些情况下甚至可以用作_de facto_标准实现。它们可以免费使用,并且由https://zeppelin.solutions[Zeppelin]和不断增长的外部贡献者列表构建和修复。
进一步阅读
应用程序二进制接口(ABI)是强类型的,在编译时和静态时都是已知的。所有合约都有他们打算在编译时调用的任何合约的接口定义。
关于Ethereum ABI的更严格和更深入的解释可以在这找到: [https://solidity.readthedocs.io/en/develop/abi-spec.html](https://solidity.readthedocs.io/en/develop/abi-spec.html)
. 该链接包括有关编码的正式说明和各种有用示例的详细信息。