使用Solidity编程
在本节中,我们将看看Solidity语言的一些功能。正如我们在 [intro] 中提到的,我们的第一份合约示例非常简单,并且在许多方面也存在缺陷。我们将逐渐改进这个例子,同时学习如何使用Solidity。然而,这不会是一个全面的Solidity教程,因为Solidity相当复杂且快速发展。我们将介绍基础知识,并为你提供足够的基础,以便能够自行探索其余部分。Solidity的完整文档可以在以下网址找到:
https://solidity.readthedocs.io/en/latest/
数据类型
首先,我们来看看Solidity中提供的一些基本数据类型:
boolean (bool)
布尔值, true 或 false, 以及逻辑操作符 ! (not), && (and), || (or), == (equal), != (not equal).
整数 (int/uint)
有符号 (int) 和 无符号 (uint) 整数,从 u/int8 到 u/int256以 8 bits 递增,没有大小后缀的话,表示256 bits。
定点数 (fixed/ufixed)
定点数, 定义为 u/fixedMxN,其中 M 是位大小(以8递增),N 是小数点后的十进制数的个数。
地址
20字节的以太坊地址。address 对象有 balance (返回账户的余额) 和 transfer (转移ether到该账户) 成员方法。
字节数组(定长)
固定大小的字节数组,定义为+bytes1+到+bytes32+。
字节数组 (动态)
动态大小的字节数组,定义为+bytes+或+string+。
enum
枚举离散值的用户定义类型。
struct
包含一组变量的用户定义的数据容器。
mapping
+key ⇒ value+对的哈希查找表。
除上述数据类型外,Solidity还提供了多种可用于计算不同单位的字面值:
时间单位
seconds, minutes, hours, 和 days 可用作后缀,转换为基本单位 seconds 的倍数。
以太的单位
wei, finney, szabo, 和 ether 可用作后缀, 转换为基本单位 wei 的倍数。
到目前为止,在我们的+Faucet+合约示例中,我们使用+uint+(这是+uint256+的别名),用于+withdraw_amount+变量。我们还间接使用了+address+变量,它是+ msg.sender+。在本章中,我们将在示例中使用更多数据类型。
让我们使用单位的倍数之一来提高示例合约+Faucet+的可读性。在+withdraw+函数中,我们限制最大提现额,将数量限制表示为+wei+,以太的基本单位:
require(withdraw_amount <= 100000000000000000);
这不是很容易阅读,所以我们可以通过使用单位倍数 ether 来改进我们的代码,以ether而不是wei表示值:
require(withdraw_amount <= 0.1 ether);
预定义的全局变量和函数
在EVM中执行合约时,它可以访问一组较小范围内的全局对象。这些包括 block,msg 和 tx 对象。另外,Solidity公开了许多EVM操作码作为预定义的Solidity功能。在本节中,我们将检查你可以从Solidity的智能合约中访问的变量和函数。
调用交易/消息上下文
msg
+msg+对象是启动合约执行的交易(源自EOA)或消息(源自合约)。它包含许多有用的属性:
msg.sender
我们已经使用过这个。它代表发起消息的地址。如果我们的合约是由EOA交易调用的,那么这是签署交易的地址。
msg.value
与消息一起发送的以太网值。
msg.gas
调用我们的合约的消息中留下的gas量。它已经被弃用,并将被替换为Solidity v0.4.21中的gasleft()函数。
msg.data
调用合约的消息中的数据。
msg.sig
数据的前四个字节,它是函数选择器。
Note | 每当合约调用另一个合约时,msg+的所有属性的值都会发生变化,以反映新的调用者的信息。唯一的例外是在原始+msg+上下文中运行另一个合约/库的代码的 +delegatecall 函数。 |
交易上下文
tx.gasprice
发起调用的交易中的gas价格。
tx.origin
源自(EOA)的交易的完整调用堆栈。
区块上下文
block
包含有关当前块的信息的块对象。
block.blockhash(blockNumber)
指定块编号的块的哈希,直到之前的256个块。已弃用,并使用Solidity v.0.4.22中的+blockhash()+函数替换。
block.coinbase
当前块的矿工地址。
block.difficulty
当前块的难度(Proof-of-Work)。
block.gaslimit
当前块的区块gas限制。
block.number
当前块号(高度)。
block.timestamp
矿工在当前块中放置的时间戳,自Unix纪元(秒)开始。
地址对象
任何地址(作为输入传递或从合约对象转换而来)都有一些属性和方法:
address.balance
地址的余额,以wei为单位。例如,当前合约余额是 address(this).balance。
address.transfer(amount)
将金额(wei)转移到该地址,并在发生任何错误时抛出异常。我们在+Faucet+示例中的+msg.sender+地址上使用了此函数,msg.sender.transfer()。
address.send(amount)
类似于前面的+transfer+, 但是失败时不抛出异常,而是返回+false+。
address.call()
低级调用函数,可以用+value+,data+构造任意消息。错误时返回+false。
address.delegatecall()
低级调用函数,保持发起调用的合约的+msg+上下文,错误时返回+false+。
内置函数
addmod, mulmod
模加法和乘法。例如,addmod(x,y,k) 计算 (x + y) % k。
keccak256, sha256, sha3, ripemd160
用各种标准哈希算法计算哈希值的函数。
ecrecover
从签名中恢复用于签署消息的地址。
合约的定义
Solidity的主要数据类型是_contract_对象,它在我们的+Faucet+示例的顶部定义。与面向对象语言中的任何对象类似,合约是一个包含数据和方法的容器。
Solidity提供了另外两个与合约类似的对象:
interface
接口定义的结构与合约完全一样,只不过没有定义函数,它们只是声明。这种类型的函数声明通常被称为 桩 stub,因为它告诉你有关函数的参数和返回值,没有任何实现。它用来指定合约接口,如果继承,每个函数都必须在子类中指定。
library
一个库合约是一个只能部署一次并被其他合约使用的合约,使用+delegatecall+方法(见地址对象)。
函数
在合约中,我们定义了可以由EOA交易或其他合约调用的函数。在我们的+Faucet+示例中,我们有两个函数:+withdraw+和(未命名的)_fallback_函数。
函数使用以下语法定义:
function FunctionName([parameters]) {public|private|internal|external} [pure|constant|view|payable] [modifiers] [returns (<return types>)]
我们来看看每个组件:
FunctionName
定义函数的名称,用于通过交易(EOA),其他合约或同一合约调用函数。每个合约中的一个功能可以定义为不带名称的,在这种情况下,它是_fallback_函数,在没有指定其他函数时调用该函数。fallback函数不能有任何参数或返回任何内容。
parameters
在名称后面,我们指定必须传递给函数的参数,包括名称和类型。在我们的+Faucet+示例中,我们将+uint withdraw_amount+定义为+withdraw+函数的唯一参数。
下一组关键字 (public, private, internal, external) 指定了函数的_可见性_:
public
Public是默认的,这些函数可以被其他合约,EOA交易或合约内部调用。在我们的+Faucet+示例中,这两个函数都被定义为public。
external
外部函数就像public一样,但除非使用关键字this作为前缀,否则它们不能从合约中调用。
internal
内部函数只能在合约内部”可见”,不能被其他合约或EOA交易调用。他们可以被派生合约调用(继承的)。
private
private函数与内部函数类似,但不能由派生的合约调用(继承的)。
请记住,术语 internal 和 private 有些误导性。公共区块链中的任何函数或数据总是_可见的_,意味着任何人都可以看到代码或数据。以上关键字仅影响函数的调用方式和时机。
下一组关键字(pure, constant, view, payable)会影响函数的行为:
constant/view
标记为_view_的函数,承诺不修改任何状态。术语_constant_是_view_的别名,将被弃用。目前,编译器不强制执行_view_修饰器,只产生一个警告,但这应该成为Solidity v0.5中的强制关键字。
pure
纯(pure)函数不读写任何变量。它只能对参数进行操作并返回数据,而不涉及任何存储的数据。纯函数旨在鼓励没有副作用或状态的声明式编程。
payable
payable函数是可以接受付款的功能。没有payable的函数将拒绝收款,除非它们来源于coinbase(挖矿收入)或 作为 SELFDESTRUCT(合约终止)的目的地。在这些情况下,由于EVM中的设计决策,合约无法阻止收款。
正如你在+Faucet+示例中看到的那样,我们有一个payable函数(fallback函数),它是唯一可以接收付款的函数。
合约构造和自毁
有一个特殊函数只能使用一次。创建合约时,它还运行 构造函数 constructor function(如果存在),以初始化合约状态。构造函数与创建合约时在同一个交易中运行。构造函数是可选的。事实上,我们的+Faucet+示例没有构造函数。
构造函数可以通过两种方式指定。到Solidity v.0.4.21,构造函数是一个名称与合约名称相匹配的函数:
Constructor function prior to Solidity v0.4.22
contract MEContract {
function MEContract() {
// This is the constructor
}
}
这种格式的难点在于如果合约名称被改变并且构造函数名称没有改变,它就不再是构造函数了。这可能会导致一些非常令人讨厌的,意外的并且很难注意到的错误。想象一下,例如,如果构造函数正在为控制目的而设置合约的“所有者”。它不仅可以在创建合约时设置所有者,还可以像正常功能那样“可调用”,允许任何第三方在合约创建后劫持合约并成为“所有者”。
为了解决构造函数的潜在问题,它基于与合约名称相同的名称,Solidity v0.4.22引入了一个+constructor+关键字,它像构造函数一样运行,但没有名称。重命名合约并不会影响构造函数。此外,更容易确定哪个函数是构造函数。看起来像这样:
pragma ^0.4.22
contract MEContract {
constructor () {
// This is the constructor
}
}
总而言之,合约的生命周期始于EOA或其他合约的创建交易。如果有一个构造函数,它将在相同的创建交易中调用,并可以在创建合约时初始化合约状态。
合约生命周期的另一端是 合约销毁 contract destruction。合约被称为+SELFDESTRUCT+的特殊EVM操作码销毁。它曾经是+SUICIDE+,但由于该词的负面性,该名称已被弃用。在Solidity中,此操作码作为高级内置函数+selfdestruct+公开,该函数采用一个参数:地址以接收合约帐户中剩余的余额。看起来像这样:
selfdestruct(address recipient);
添加一个构造函数和selfdestruct到我们的+Faucet+示例
我们在[intro]中引入的+Faucet+示例合约没有任何构造函数或自毁函数。这是永恒的合约,不能从区块链中删除。让我们通过添加一个构造函数和selfdestruct函数来改变它。我们可能希望自毁仅由最初创建合约的EOA来调用。按照惯例,这通常存储在称为+owner+的地址变量中。我们的构造函数设置所有者变量,并且selfdestruct函数将首先检查是否是所有者调用它。
首先是我们的构造函数:
// Version of Solidity compiler this program was written for
pragma solidity ^0.4.22;
// Our first contract is a faucet!
contract Faucet {
address owner;
// Initialize Faucet contract: set owner
constructor() {
owner = msg.sender;
}
[...]
我们已经更改了pragma指令,将v0.4.22指定为此示例的最低版本,因为我们使用的是仅存在于Solidity v.0.4.22中的constructor关键字。我们的合约现在有一个名为+owner+的+address+类型变量。名称“owner”不是特殊的。我们可以将这个地址变量称为“potato”,仍然以相同的方式使用它。名称+owner+只是简单明了的目的和目的。
然后,作为合约创建交易的一部分运行的constructor函数将+msg.sender+的地址分配给+owner+变量。我们使用 withdraw 函数中的 msg.sender 来 标识提款请求的来源。然而,在构造函数中,+msg.sender+是签署合约创建交易的EOA或合约地址。这是事实,因为这是一个构造函数:它只运行一次,并且仅作为合约创建交易的结果。
好的,现在我们可以添加一个函数来销毁合约。我们需要确保只有所有者才能运行此函数,因此我们将使用+require+语句来控制访问。看起来像这样:
// Contract destructor
function destroy() public {
require(msg.sender == owner);
selfdestruct(owner);
}
如果其他人用 owner 以外的地址调用 destroy 函数,则将失败。但是,如果构造函数存储在 owner 中的地址调用它,合约将自毁,并将剩余余额发送到 owner 地址。
函数修饰器
Solidity提供了一种称为_函数修饰器_的特殊类型的函数。通过在函数声明中添加修饰器名称,可以将修饰器应用于函数。修饰器函数通常用于创建适用于合约中许多函数的条件。我们已经在我们的+destroy+函数中有一个访问控制语句。让我们创建一个表达该条件的函数修饰器:
onlyOwner function modifier
modifier onlyOwner {
require(msg.sender == owner);
_;
}
在 onlyOwner function modifier 中,我们看到函数修饰器的声明,名为+onlyOwner+。此函数修饰器为其修饰的任何函数设置条件,要求存储为合约的+owner+的地址与交易的+msg.sender+的地址相同。这是访问控制的基本设计模式,只允许合约的所有者执行具有+onlyOwner+修饰器的任何函数。
你可能已经注意到我们的函数修饰器在其中有一个特殊的语法“占位符”,下划线后跟分号(_;)。此占位符由正在修饰的函数的代码替换。本质上,修饰器“修饰”修饰过的函数,将其代码置于由下划线字符标识的位置。
要应用修饰器,请将其名称添加到函数声明中。可以将多个修饰器应用于一个函数,作为逗号分隔的列表,以声明的顺序应用。
让我们重新编写+destroy+函数来使用+onlyOwner+修饰器:
function destroy() public onlyOwner {
selfdestruct(owner);
}
函数修饰器的名称(onlyOwner)位于关键字+public+之后,并告诉我们+destroy+函数由+onlyOwner+修饰器修饰。基本上你可以把它写成:“只有所有者才能销毁这份合约”。实际上,生成的代码相当于由+onlyOwner+ “包装” 的+destroy+代码。
函数修饰器是一个非常有用的工具,因为它们允许我们为函数编写前提条件并一致地应用它们,使代码更易于阅读,因此更易于审计安全问题。它们最常用于访问控制,如示例中的“function_modifier_onlyowner”,但功能很多,可用于各种其他目的。
在修饰函数内部,可以访问被修饰的函数的的所有可见符号(变量和参数)。在这种情况下,我们可以访问在合约中声明的+owner+变量。但是,反过来并不正确:你无法访问修饰函数中的任何变量。
合约继承
Solidity的合约对象支持 继承,这是一种用附加功能扩展基础合约的机制。要使用继承,请使用关键字+is+指定父合约:
contract Child is Parent {
}
通过这个构造,+Child+合约继承了+Parent+的所有方法,功能和变量。Solidity还支持多重继承,可以在关键字+is+之后用逗号分隔的合约名称指定多重继承:
contract Child is Parent1, Parent2 {
}
合约继承使我们能够以实现模块化,可扩展性和重用的方式编写我们的合约。我们从简单的合约开始,实现最通用的功能,然后通过在更具体的合约中继承这些功能来扩展它们。
在我们的+Faucet+合约中,我们引入了构造函数和析构函数,以及为构建时指定的owner提供的访问控制。这些功能非常通用:许多合约都有它们。我们可以将它们定义为通用合约,然后使用继承将它们扩展到+Faucet+合约。
我们首先定义一个基础合约+owned+,它拥有一个+owner+变量,并在合约的构造函数中设置:
contract owned {
address owner;
// Contract constructor: set owner
constructor() {
owner = msg.sender;
}
// Access control modifier
modifier onlyOwner {
require(msg.sender == owner);
_;
}
}
接下来,我们定义一个基本合约 mortal,继承自 owned:
contract mortal is owned {
// Contract destructor
function destroy() public onlyOwner {
selfdestruct(owner);
}
}
如你所见,mortal 合约可以使用在+owned+中定义的+ownOwner+函数修饰器。它间接地也使用+owner+ address变量和+owned+中定义的构造函数。继承使每个合约变得更简单,并专注于其类的特定功能,使我们能够以模块化的方式管理细节。
现在我们可以进一步扩展+owned+合约,在+Faucet+中继承其功能:
contract Faucet is mortal {
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
// Limit withdrawal amount
require(withdraw_amount <= 100000000000000000);
// Send the amount to the address that requested it
msg.sender.transfer(withdraw_amount);
}
// Accept any incoming amount
function () public payable {}
}
通过继承+mortal+,继而继承+owned+,+Faucet+合约现在具有构造函数和销毁函数以及定义的owner。这些功能与+Faucet+中的功能相同,但现在我们可以在其他合约中重用这些功能而无需再次写入它们。代码重用和模块化使我们的代码更清晰,更易于阅读,并且更易于审计。
错误处理(assert, require, revert)
合约调用可以终止并返回错误。Solidity中的错误由四个函数处理:assert, require, revert, 和 throw(现在已弃用)。
当合约终止并出现错误时,如果有多个合约被调用,则所有状态变化(变量,余额等的变化)都会恢复,直至合约调用链的源头。这确保交易是原子的,这意味着它们要么成功完成,要么对状态没有影响,并完全恢复。
assert+和+require+函数以相同的方式运行,如果条件为假,则评估条件并停止执行并返回错误。按照惯例,当结果预期为真时使用+assert,这意味着我们使用+assert+来测试内部条件。相比之下,在测试输入(例如函数参数或交易字段)时使用+require+,设置我们对这些条件的期望。
我们在函数修饰器+onlyOwner+中使用了+require+来测试消息发送者是合约的所有者:
require(msg.sender == owner);
require 函数充当_守护条件_,阻止执行函数的其余部分,并在不满足时产生错误。
从Solidity v.0.4.22开始,+require+还可以包含有用的文本消息,可用于显示错误的原因。错误消息记录在交易日志中。所以我们可以通过在我们的+require+函数中添加一条错误消息来改进我们的代码:
require(msg.sender == owner, "Only the contract owner can call this function");
revert 和 throw 函数,停止执行合约并还原任何状态更改。+throw+函数已过时,将在未来版本的Solidity中删除 - 你应该使用+revert+代替。+revert+函数还可以将作为唯一参数的错误消息记录在交易日志中。
无论我们是否明确检查它们,合约中的某些条件都会产生错误。例如,在我们的+Faucet+合约中,我们不检查是否有足够的ether来满足提款请求。这是因为如果没有足够的余额进行转账,+transfer+函数将失败并恢复交易:
The transfer function will fail if there is an insufficient balance
msg.sender.transfer(withdraw_amount);
但是,最好明确检查,并在失败时提供明确的错误消息。我们可以通过在转移之前添加一个require语句来实现这一点:
require(this.balance >= withdraw_amount,
"Insufficient balance in faucet for withdrawal request");
msg.sender.transfer(withdraw_amount);
像这样的其他错误检查代码会略微增加gas消耗,但它比不检查提供了更好的错误报告。在gas量和详细错误检查之间取得适当的平衡是你需要根据合约的预期用途来决定的。在为测试网络设计的+Faucet+的情况下,即使额外报告成本更高,我们也不冒险犯错。也许对于一个主网合约,我们会选择节约gas用量。
事件(Events)
事件是便于生产交易日志的Solidity构造。当一个交易完成(成功与否)时,它会产生一个 交易收据 transaction receipt,就像我们在 [evm] 中看到的那样。交易收据包含_log_条目,用于提供有关在执行交易期间发生的操作的信息。事件是用于构造这些日志的Solidity高级对象。
事件在轻量级客户端和DApps中特别有用,它可以“监视”特定事件并将其报告给用户界面,或对应用程序的状态进行更改以反映底层合约中的事件。
事件对象接收序列化的参数并记录在区块链的交易日志中。你可以在参数之前应用关键字+indexed+,以使其值作为索引表(哈希表)的一部分,可以由应用程序搜索或过滤。
到目前为止,我们还没有在我们的+Faucet+示例中添加任何事件,所以让我们来做。我们将添加两个事件,一个记录任何提款,一个记录任何存款。我们将分别称这些事件+Withdrawal+和+Deposit+。首先,我们在+Faucet+合约中定义事件:
contract Faucet is mortal {
event Withdrawal(address indexed to, uint amount);
event Deposit(address indexed from, uint amount);
[...]
}
我们选择将地址标记为+indexed+,以允许任何访问我们的+Faucet+的用户界面中搜索和过滤。
接下来,我们使用 emit 关键字将事件数据合并到交易日志中:
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
[...]
msg.sender.transfer(withdraw_amount);
emit Withdrawal(msg.sender, withdraw_amount);
}
// Accept any incoming amount
function () public payable {
emit Deposit(msg.sender, msg.value);
}
Faucet.sol 合约现在看起来像:
Faucet8.sol: Revised Faucet contract, with events
link:code/Solidity/Faucet8.sol[]
// Version of Solidity compiler this program was written for
pragma solidity ^0.4.22;
contract owned {
address owner;
// Contract constructor: set owner
constructor() {
owner = msg.sender;
}
// Access control modifier
modifier onlyOwner {
require(msg.sender == owner, "Only the contract owner can call this function");
_;
}
}
contract mortal is owned {
// Contract destructor
function destroy() public onlyOwner {
selfdestruct(owner);
}
}
contract Faucet is mortal {
event Withdrawal(address indexed to, uint amount);
event Deposit(address indexed from, uint amount);
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
// Limit withdrawal amount
require(withdraw_amount <= 0.1 ether);
require(this.balance >= withdraw_amount,
"Insufficient balance in faucet for withdrawal request");
// Send the amount to the address that requested it
msg.sender.transfer(withdraw_amount);
emit Withdrawal(msg.sender, withdraw_amount);
}
// Accept any incoming amount
function () public payable {
emit Deposit(msg.sender, msg.value);
}
}
捕捉事件
好的,所以我们已经建立了我们的合约来发布事件。我们如何看到交易的结果并“捕捉”事件?+web3.js+库提供一个数据结构,作为包含交易日志的交易的结果。在那里,我们可以看到交易产生的事件。
让我们使用+truffle+在修订的+Faucet+合约上运行测试交易。按照 [truffle] 中的说明设置项目目录并编译+Faucet+代码。源代码可以在本书的GitHub存储库中找到:
code/truffle/FaucetEvents
$ truffle develop
truffle(develop)> compile
truffle(develop)> migrate
Using network 'develop'.
Running migration: 1_initial_migration.js
Deploying Migrations...
... 0xb77ceae7c3f5afb7fbe3a6c5974d352aa844f53f955ee7d707ef6f3f8e6b4e61
Migrations: 0x8cdaf0cd259887258bc13a92c0a6da92698644c0
Saving successful migration to network...
... 0xd7bc86d31bee32fa3988f1c1eabce403a1b5d570340a3a9cdba53a472ee8c956
Saving artifacts...
Running migration: 2_deploy_contracts.js
Deploying Faucet...
... 0xfa850d754314c3fb83f43ca1fa6ee20bc9652d891c00a2f63fd43ab5bfb0d781
Faucet: 0x345ca3e014aaf5dca488057592ee47305d9b3e10
Saving successful migration to network...
... 0xf36163615f41ef7ed8f4a8f192149a0bf633fe1a2398ce001bf44c43dc7bdda0
Saving artifacts...
truffle(develop)> Faucet.deployed().then(i => {FaucetDeployed = i})
truffle(develop)> FaucetDeployed.send(web3.toWei(1, "ether")).then(res => { console.log(res.logs[0].event, res.logs[0].args) })
Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }
truffle(develop)> FaucetDeployed.withdraw(web3.toWei(0.1, "ether")).then(res => { console.log(res.logs[0].event, res.logs[0].args) })
Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }
用+deployed()函数获得部署的合约后,我们执行两个交易。第一笔交易是一笔存款(使用+send),在交易日志中发出+Deposit+事件:
Deposit { from: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 18, c: [ 10000 ] } }
接下来,我们使用+withdraw+函数进行提款。这会发出+Withdrawal+事件:
Withdrawal { to: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
amount: BigNumber { s: 1, e: 17, c: [ 1000 ] } }
为了获得这些事件,我们查看了作为结果(res)返回的+logs+数组。第一个日志条目(logs[0])包含+logs[0].event+的事件名称和+logs[0].args+的事件参数。通过在控制台上显示这些信息,我们可以看到发出的事件名称和事件参数。
事件是一种非常有用的机制,不仅适用于合约内通信,还适用于开发过程中的调试。
调用其他合约 (call, send, delegatecall, callcode)
在合约中调用其他合约是非常有用但有潜在危险的操作。我们将研究你可以实现的各种方法并评估每种方法的风险。
创建一个新的实例
调用另一份合约最安全的方法是你自己创建其他合约。这样,你就可以确定它的接口和行为。要做到这一点,你可以简单地使用关键字+new+来实例化它,就像任何面向对象的语言一样。在Solidity中,关键字+new+将在区块链上创建合约并返回一个可用于引用它的对象。假设你想从另一个名为+Token+的合约中创建并调用+Faucet+合约:
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = new Faucet();
}
}
这种合约建造机制确保你知道合约的确切类型及其接口。合约+Faucet+必须在+Token+范围内定义,如果定义位于另一个文件中,你可以使用+import+语句来执行此操作:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = new Faucet();
}
}
+new+关键字还可以接受可选参数来指定创建时传输的ether+值+以及传递给新合约构造函数的参数(如果有):
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = (new Faucet).value(0.5 ether)();
}
}
如果我们赋予创建的+Faucet+一些ether,我们也可以调用+Faucet+函数,它们就像方法调用一样操作。在这个例子中,我们从+Token+的+destroy+函数中调用+Faucet+的+destroy+函数:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor() {
_faucet = (new Faucet).value(0.5 ether)();
}
function destroy() ownerOnly {
_faucet.destroy();
}
}
访问现有的实例
我们可以用来调用合约的另一种方法是将现有合约的地址转换为实例。使用这种方法,我们将已知接口应用于现有实例。因此,我们需要确切地知道,我们正在处理的事例实际上与我们所假设的类型相同,这一点非常重要。我们来看一个例子:
import "Faucet.sol"
contract Token is mortal {
Faucet _faucet;
constructor(address _f) {
_faucet = Faucet(_f);
_faucet.withdraw(0.1 ether)
}
}
在这里,我们将地址作为参数提供给构造函数,并将其作为+Faucet+对象进行转换。这比以前的机制风险大得多,因为我们实际上并不知道该地址是否实际上是+Faucet+对象。当我们调用+withdraw+时,我们假设它接受相同的参数并执行与我们的+Faucet+声明相同的代码,但我们无法确定。就我们所知,在这个地址的+withdraw+函数可以执行与我们所期望的完全不同的事情,即使它的命名相同。因此,使用作为输入传递的地址并将它们转换成特定的对象中比自己创建合约要危险得多。
原始调用, delegatecall
Solidity为调用其他合约提供了一些更“低级”的功能。它们直接对应于具有相同名称的EVM操作码,并允许我们手动构建合约到合约的调用。因此,它们代表了调用其他合约最灵活和最危险的机制。
以下是使用 call 方法的相同示例:
contract Token is mortal {
constructor(address _faucet) {
_faucet.call("withdraw", 0.1 ether);
}
}
正如你所看到的,这种类型的+call+,是一个函数的_盲_ blind_调用,就像构建一个原始交易一样,只是在合约的上下文中。它可能会使我们的合约面临一些安全风险,最重要的是 _可重入性 reentrancy,我们将在 [reentrancy] 中更详细地讨论。如果出现问题,+call+函数将返回false,所以我们可以评估返回值以进行错误处理:
contract Token is mortal {
constructor(address _faucet) {
if !(_faucet.call("withdraw", 0.1 ether)) {
revert("Withdrawal from faucet failed");
}
}
}
call+的另一个变体是+delegatecall,它取代了更危险的+callcode+。+callcode+方法很快就会被弃用,所以不应该使用它。
正如地址对象中提到的,delegatecall+不同于+call,因为+msg+上下文不会改变。例如,call 将 msg.sender 的值更改为发起调用的合约,而+delegatecall+保持与发起调用的合约中的+msg.sender+相同。基本上,+delegatecall+在当前合约的上下文中运行另一个合约的代码。它最常用于从+library+调用代码。
应该谨慎使用+delegatecall+。它可能会有一些意想不到的效果,特别是如果你调用的合约不是作为库设计的。
让我们使用示例合约来演示+call+和+delegatecall+用于调用库和合约的各种调用语义。我们使用一个事件来记录每个调用的来源,并根据调用类型了解调用上下文如何变化:
CallExamples.sol: An example of different call semantics.
link:code/truffle/CallExamples/contracts/CallExamples.sol[]
pragma solidity ^0.4.22;
contract calledContract {
event callEvent(address sender, address origin, address from);
function calledFunction() public {
emit callEvent(msg.sender, tx.origin, this);
}
}
library calledLibrary {
event callEvent(address sender, address origin, address from);
function calledFunction() public {
emit callEvent(msg.sender, tx.origin, this);
}
}
contract caller {
function make_calls(calledContract _calledContract) public {
// Calling the calledContract and calledLibrary directly
_calledContract.calledFunction();
calledLibrary.calledFunction();
// Low level calls using the address object for calledContract
require(address(_calledContract).call(bytes4(keccak256("calledFunction()"))));
require(address(_calledContract).delegatecall(bytes4(keccak256("calledFunction()"))));
}
}
我们的主要合约是+caller+,它调用库 calledLibrary 和合约 calledContract。被调用的库和合约有相同的函数 calledFunction,发送+calledEvent+事件。calledEvent+事件记录三个数据:+msg.sender, tx.origin, 和 this。每次调用+calledFunction+时,都会有不同的上下文(不同的 msg.sender)取决于它是直接调用还是通过 delegatecall 调用。
在+caller+中,我们首先直接调用合约和库的calledFunction()。然后,我们直接使用低级函数+call+和+delegatecall+调用+calledContract.calledFunction+。观察多种调用机制的行为。
让我们在truffle开发环境中运行并捕捉事件:
truffle(develop)> migrate
Using network 'develop'.
[...]
Saving artifacts...
truffle(develop)> web3.eth.accounts[0]
'0x627306090abab3a6e1400e9345bc60c78a8bef57'
truffle(develop)> caller.address
'0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
truffle(develop)> calledContract.address
'0x345ca3e014aaf5dca488057592ee47305d9b3e10'
truffle(develop)> calledLibrary.address
'0xf25186b5081ff5ce73482ad761db0eb0d25abfbf'
truffle(develop)> caller.deployed().then( i => { callerDeployed = i })
truffle(develop)> callerDeployed.make_calls(calledContract.address).then(res => { res.logs.forEach( log => { console.log(log.args) })})
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }
让我们看看发生了什么。我们调用+make_calls+函数并传递+calledContract+的地址,然后捕获不同调用发出的四个事件。查看+make_calls+函数,让我们逐步了解每一步。
第一个调用:
_calledContract.calledFunction();
在这里,我们直接调用+calledContract.calledFunction+,使用称为callFunction的高级ABI。发出的事件是:
sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10'
如你所见,msg.sender+是+caller+合约的地址。+tx.origin+是我们的钱包+web3.eth.accounts[0]+的地址,钱包将交易发送给+caller。该事件由+calledContract+发出,我们从事件中的最后一个参数可以看到。
+make_calls+中的下一次调用是对库的调用:
calledLibrary.calledFunction();
它看起来与我们调用合约的方式完全相同,但行为非常不同。我们来看看发出的第二个事件:
sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
这一次,msg.sender+不是+caller+的地址。相反,它是我们钱包的地址,与交易来源相同。这是因为当你调用一个库时,这个调用总是+delegatecall+并且在调用者的上下文中运行。所以,当+calledLibrary+代码运行时,它继承+caller+的执行上下文,就好像它的代码在+caller+中运行一样。变量+this(在发出的事件中显示为+from+)是+caller+的地址,即使它是从+calledLibrary+内部访问的。
接下来的两个调用,使用低级+call+和+delegatecall+,验证我们的期望,发出与我们刚刚看到的事件相同的结果。