智能合约中的函数调用
交易的大致流程
根据黄皮书的规定,交易分为两种,分别是message call与contract creation。
一个交易的执行可以被表示为函数
其中
那么如何根据
存档
从初始状态转移到checkpoint
可以看到,我们顶格扣除了gas费,同时将账户的nonce+1。注意该操作是不可逆的,nonce增加后一定不会减少。但在交易执行的最后一个阶段,没有用完的gas费会被返还。
合约的执行
调用目标账户的代码,这里我们引入了一个新的函数
可以看到,该函数的参数很多,从左到右依次是
- 执行合约前的世界状态
- 累积交易子状态
(其中记录了交易中自毁,创建的账户,合约产生的日志,将Storage slot置0产生的退款总额等), - 该合约的调用者
,即solidity中的 msg.sender
(对于合约间的调用,这一项可能是caller合约,而不是) - 交易的创造者
,即solidity中的 tx.origin
- 交易的接收者
,这里“接受者”指执行合约代码使用的账户。 - 拥有将要被执行的代码的账户
,注意这个值大部分情况下与 相同,但是这两个实际上有根本性的差别。例如 执行时影响到了storage,那么会改变的 storageRoot
是而不是 gasLimit
gasPrice
value
,这个值是合约的调用账户 发送往合约接收账户的金额 ,注意这个值与 msg.value
可能不同- 执行环境中的
value
,即solidity中的 msg.value
,该值在delegatecall的情况下与可能不同(后面会提到)。 - 作为输入数据的byte array
,即solidity中的 msg.data
- 当前合约调用的栈深度(即caller的个数)
,初始为0 - 修改状态的权限
,对应solidity中的 view, pure
等修饰符
该函数的返回值从左到右依次为执行合约后的世界状态;剩余gas;新的累积子状态;状态码(0表示失败,1表示成功);合约代码的返回值(输出)。
当本次交易是一个message call时,
从这个调用中我们可以注意到有趣的几点:
- 合约调用者和交易创造者都是
- 执行合约的账户和拥有待执行代码的账户都是
- 我们转账的金额和合约中调用
msg.value
得到的金额相同, - 交易的可用gas费是gasLimit扣除一个固有值得到的结果。对于一个message
call来说,这个固有值
包含了输入数据data应付的费用和交易本身应付的费用(21000)
接下来,在
其中
: 拥有实际执行的代码的账户,初始为交易的接收者 (注意这里是 而不是 ,后面会在 DELEGATECALL
中提到这一点): 创造了该交易的账户,初始化为 ,即solidity中的 tx.origin
: 本次代码执行所属的交易的 gasPrice
,初始化为: 执行的代码的输入数据,这个值初始化为 。即 msg.data
: 直接导致本次代码执行的账户,初始化为 。即 msg.sender
。: 执行环境中的 value
值,初始化为(注意不是 ,边界情况我们后面会在 DELEGATECALL
中提到)。: 将要被执行的byte array,初始化为 : 当前调用栈的深度,即到目前为止执行 CALL
和CREATE(2)
的次数(统计进CREATE(2)
是因为合约创建同样会执行初始化代码,即递归调用函数),初始化为 : 当前要执行的代码对状态的修改权限,初始化为
黄皮书使用了一种迭代的方式来定义合约的执行。注意我们现在已经有了合约执行的环境参数,但仍然缺少合约执行时机器状态的定义,我们将其定义为
:当前剩余的gas,初始为 函数的参数 :指令计数器,指向即将执行的下一条指令,初始为0 :内存,内存大小为 ,其中所有值初始化为0 :内存中活跃字的数量,其中活跃字指内存中被取出过的字和被存入的字,初始为0。 仅仅是一个约数,它的值是从0地址到当前拥有最高地址的活跃字之间可能存在的活跃字的最大数量。详见黄皮书附录H中的 MLOAD
指令。:栈空间,其中 总是栈顶,初始为空 :当发生合约间调用时,用于保存callee的返回值,初始为空(只有 CALL
以及CREATE
系列指令会设置这个值,CALL
指令会将其设置为callee的返回值,CREATE
指令会将其设置为,也许是因为 CREATE
指令调用的init代码返回的总是要进行部署的合约代码,保存它没有意义)
注意机器状态
注意上面的定义中
我们定义
为接下来将要执行的指令(指令寄存器越界时默认为 : 可以看到我们需要考虑到正常执行和分支指令的情况,具体细节不再赘述。
我们将正常的停止状态定义为
,合约停止时,该函数的值便是合约代码的返回值: 注意当合约代码正常执行时,该函数的返回值为空集。
最后,我们可以给出每一条指令的执行函数
的定义:其中
是一个gas费函数,用于计算当前指令执行后需要花费的gas,详细定义较为复杂,不再赘述。 , 以及 定义了分支指令以及正常执行情况下下一条指令的地址。 注意上面的定义仅仅是一个“基础”定义,它仅仅规定了必须进行的操作:栈的变化,gas费的扣除,PC的转移。 具体指令的执行仍然可能改变其他的机器状态,世界状态以及累积状态。每个指令拥有自己的形式化定义的状态转移函数,详见黄皮书附录H。 完成了对每一条指令的执行的形式化定义后,我们最终可以定义整个合约代码的执行函数:
可以看到,该函数迭代的执行合约中的每一条指令。注意当合约执行出错或者revert时,得到的世界状态为
最终
- 若该异常是已定义行为(即合约检测到错误主动引起的revert,此时合约输出
),那么 - 若该异常为未定义行为,即
。我们将取走所有的gas,即
当交易成功时,
收尾工作
回顾前面的定义,对于message call的情形,我们可以看到调用链
注意返回值被略去了,因为交易不需要知道合约返回值,返回值仅用于合约间函数调用,如果需要在交易中得到函数返回值,可以将其作为事件log出来,这些log会进入累积状态
,最终随着交易收据 返回。
合约执行完毕后,我们只需做一些收尾工作,如返还剩余的gas,删除死账户等操作即可。这些在黄皮书中有详细的定义,这里不再赘述。
合约间函数调用
黄皮书中同样规定了合约间函数调用的流程,我们接下来对这一部分内容进行讨论。
CALL
与STATICCALL
当执行CALL
指令时,我们实际是在调用其他合约的代码,根据之前的描述,我们仍然需要使用CALL
指令总共有6个参数,前两个参数指定了函数调用的目标账户以及转账金额,中间两个参数指定了调用的calldata
接下来我们通过CALL
指令的形式化定义来学习它的行为:
注意我们略去了一些关于gas和返回值等的内容。这里
首先可以看到:
- callee的调用者被设置为
,即当前执行合约的账户 - 交易的创造者为
,保持不变 - 交易的接受者和代码的持有者都被设置为
CALL
指令的参数
使用RETURNDATASIZE
或者RETURNDATACOPY
指令,它们会从
注意对
函数的递归调用同样属于交易函数 ,即合约间的函数调用不会创建新的交易。
CALL
函数在安全性上仍然有不足之处,我们无法对被调用者的权限作出进一步的限制,例如限制对状态的修改。这带来的问题是我们无法对进行CALL
调用之后的世界状态作出任何保证。
因此EIP-214添加了一个新的指令STATICCALL
,该指令的定义与CALL
的几乎相同,除了调用
- 第9和第10个参数转账金额
和执行环境金额 被设置为0 - 最后一个参数权限
被设置为 ,即没有任何修改权限
同时,STATICCALL
是具备传染性的,即被STATICCALL
调用的代码的所有子调用都是STATIC
的。
DELEGATECALL
与CALLCODE
这里我们重点讨论DELEGATECALL
,该指令深刻体现了以太坊计算架构与传统冯诺依曼架构的不同之处。
我们首先思考现有的合约部署的一个巨大的痛点:由于区块链的不可篡改性,我们无法修改一个已经部署了的合约。 假如我们的一个大型DApp的一小部分出现了问题,我们将不得不重新部署整个DApp。这将带来巨大的开销,同时用户也因此始终无法得到一个稳定的接口地址。
回顾以太坊的计算架构,我们可以发现它采用的是一种将存储和代码分离的哈佛架构。这带来的好处是我们可以在同一块存储空间上运行不同的代码。 结合以太坊本身的特性,我们考虑下面的设计:
我们将实际的处理逻辑切分为提供了不同接口的模块,同时将这些模块和实际存储空间解耦。这样我们在更新合约时就可以只更新相应的模块,然后将新的合约注册到用户接口合约中即可。
在仔细思考后我们可以提出下面两个需求;
- 我们需要一种合约调用方式,使被调用者在调用者的账户存储上代理执行
- 我们不希望主合约和模块之间发生转账,因为它们在逻辑上是统一的一个整体
有了上面的需求后,代理调用指令DELEGATECALL
就应运而生了,它的形式化定义如下:
现在回过头看之前提出的需求:
函数的参数合约调用者 被设置为 ,保证调用前后的执行环境参数中 保持不变。(msg.sender
)- 合约接受者
被设置为 ,该参数调用前后同样保持不变,由于SLOAD
,SSTORE
等指令通过 访问当前账户的存储,这意味着执行合约使用的账户保持不变。 - 拥有将要被执行的代码的账户
被设置为 ,表示将要执行的代码取自账户 - 执行环境中的
value
被设置为 ,保证调用前后执行环境参数中的 不变。(msg.value
)
转账金额
被设置为0,表示没有发生任何转账行为看到这里,我们可以发现
函数中参数 和 的区别:前者会被真正的转账到目标账户,后者仅仅存储发生交易的金额,用于初始化环境参数 。 可以说,这一对参数就是专门为解决代理执行时 的一致性问题而设计的。
我们可以看到,DELEGATECALL
保证了调用前后的执行环境的一致性,从而使其他合约的代码逻辑也能无缝衔接到当前合约的执行过程中。
参考这篇文章,下面的代码很好的阐述了使用DELEGATECALL
进行代理执行的思想:
1 |
|
注意这种设计模式可能导致安全性问题,参见这篇文章,攻击者利用
DELEGATECALL
调用无意间暴露出的修改钱包所有者的逻辑完成了一起大型盗窃。
至于CALLCODE
指令,它的出现比DELEGATECALL
要早,但是这个指令并没有实现将msg.sender
和msg.value
传递到callee执行环境中的机制。
因此在EIP-7中,DELEGATECALL
被提出,作为CALLCODE
的一个加强版。
同时,目前社区中也有将CALLCODE
移除的呼声,参见EIP-2488
Message Call与ABI
可以看到,黄皮书为我们规定了Message Call的大框架,但它同时也给了上层设施很高的自由度。我们可以将黄皮书为我们搭建的底层设施总结为:
执行Message Call对应的合约时,
不难看出,黄皮书并没有出现合约内函数调用的实现,或者如何调用solidity合约中的函数诸如此类的规定。 这就像是一个规定了基本spec的硬件,仍然缺乏让普通程序员也能愉快编程的抽象。这一层抽象被称为ABI(Application Binary Interface)。接下来我们将会对着一部分内容进行研究。
我们主要需要解决的问题是函数分发。即如何调用合约内定义的函数。
我们以下面的合约为例:
1 |
|
考虑我们目前已有的条件:
- 合约执行时一定从0地址开始执行
- 合约执行初始拥有确定的环境参数,如保存输入数据的
那么我们可以考虑创建一个分派器函数,该函数总是位于0地址处, 同时为每个函数设定签名,当我们调用某个函数时,我们发送的数据中包含该函数的签名,分派器函数进一步调用该函数。
使用solc编译上述合约后再对其进行反编译,可以看到该分配器函数(函数选择器)的全貌:
可以看到,该选择器的主要逻辑是根据calldata的前四个字节进行函数分派。这四个字节被称为function selector, 是通过函数签名(包括函数名,参数列表)进行keccak256哈希计算出来的,当我们进行函数调用时,发送的data实际上是 函数签名+参数,具体的编码形式参见官方的ABI文档.
正因为该ABI的存在,当我们调用链上合约时,我们需要知道一份对应的JSON文件,该文件描述了用于和该合约交互的ABI。该文件同样可以通过 solc编译得到,例如例子中的合约ABI表示为JSON为:
1 |
|
最后我们还遗留了一些其他问题,例如合约内函数调用,目前没有找到合适的资料,这里有我的一些个人的总结。 除此以外,还有一些涉及底层的,如变量的内存布局,可以参考官方文档。
注意合约内的函数调用是通过JUMP
来进行的,因此其传参方式与合约间调用不同。合约间调用时函数参数在calldata中,如果一个函数需要既支持合约内函数调用,又要支持合约间函数调用,
它在处理参数时就需要先把参数保存到内存中,然后再做进一步的处理,这样的函数使用public
修饰符来修饰。而只能进行合约间调用的函数可以直接从calldata中获取参数,这样的函数
使用external
修饰符来修饰。由于内存的使用是十分昂贵的,在参数相同,逻辑相同的情况下,调用public
方法会付出比external
方法更多的gas费。
捐赠
如果本文为你带来了帮助,可以向m4tsuri.eth
转账进行捐赠:)