智能合约中的函数调用


我们将会在本文中讨论与智能合约中函数调用的一切。我们讨论的范围包括黄皮书中对合约调用的形式化规定,solc中对函数调用的处理,几种不同调用方式之间的区别与联系。

#交易的大致流程

根据黄皮书的规定,交易分为两种,分别是message call与contract creation。

一个交易的执行可以被表示为函数

其中表示该交易(包含交易的各项参数),以及分别表示交易完成前后的世界状态。

那么如何根据得到呢,实际上交易的过程大致可以分为三个阶段(只考虑message call情形):

#存档

从初始状态转移到checkpoint ,这里的checkpoint是一个检查点,当交易后面出现问题时我们会回退到该状态。

可以看到,我们顶格扣除了gas费,同时将账户的nonce+1。注意该操作是不可逆的,nonce增加后一定不会减少。但在交易执行的最后一个阶段,没有用完的gas费会被返还。

#合约的执行

调用目标账户的代码,这里我们引入了一个新的函数,该函数的原型如下:

可以看到,该函数的参数很多,从左到右依次是

  1. 执行合约前的世界状态
  2. 累积交易子状态(其中记录了交易中自毁,创建的账户,合约产生的日志,将Storage slot置0产生的退款总额等),
  3. 该合约的调用者,即solidity中的msg.sender(对于合约间的调用,这一项可能是caller合约,而不是
  4. 交易的创造者,即solidity中的tx.origin
  5. 交易的接收者,这里“接受者”指执行合约代码使用的账户。
  6. 拥有将要被执行的代码的账户,注意这个值大部分情况下与相同,但是这两个实际上有根本性的差别。例如执行时影响到了storage,那么会改变的storageRoot而不是
  7. gasLimit
  8. gasPrice
  9. value ,这个值是合约的调用账户发送往合约接收账户的金额,注意这个值与msg.value可能不同
  10. 执行环境中的value ,即solidity中的msg.value,该值在delegatecall的情况下与可能不同(后面会提到)。
  11. 作为输入数据的byte array ,即solidity中的msg.data
  12. 当前合约调用的栈深度(即caller的个数),初始为0
  13. 修改状态的权限,对应solidity中的view, pure等修饰符

该函数的返回值从左到右依次为执行合约后的世界状态;剩余gas;新的累积子状态;状态码(0表示失败,1表示成功);合约代码的返回值(输出)。

当本次交易是一个message call时,函数将会被调用:

从这个调用中我们可以注意到有趣的几点:

  1. 合约调用者和交易创造者都是
  2. 执行合约的账户和拥有待执行代码的账户都是
  3. 我们转账的金额和合约中调用msg.value得到的金额相同,
  4. 交易的可用gas费是gasLimit扣除一个固有值得到的结果。对于一个message call来说,这个固有值包含了输入数据data应付的费用和交易本身应付的费用(21000)

接下来,在函数中,我们首先根据参数进行的转账(一些边界条件参见黄皮书)得到新的世界状态,然后执行中的代码,执行代码使用的函数定义为: 我们只考虑一般情况,并且没有任何错误发生,那么有:

其中表示执行环境参数,它是不可变的,其具体内容如下:

  1. : 拥有实际执行的代码的账户,初始为交易的接收者注意这里是而不是,后面会在DELEGATECALL中提到这一点)
  2. : 创造了该交易的账户,初始化为,即solidity中的tx.origin
  3. : 本次代码执行所属的交易的gasPrice,初始化为
  4. : 执行的代码的输入数据,这个值初始化为。即msg.data
  5. : 直接导致本次代码执行的账户,初始化为。即msg.sender
  6. : 执行环境中的value值,初始化为(注意不是,边界情况我们后面会在DELEGATECALL中提到)
  7. : 将要被执行的byte array,初始化为
  8. : 当前调用栈的深度,即到目前为止执行CALLCREATE(2)的次数(统计进CREATE(2)是因为合约创建同样会执行初始化代码,即递归调用函数),初始化为
  9. : 当前要执行的代码对状态的修改权限,初始化为

黄皮书使用了一种迭代的方式来定义合约的执行。注意我们现在已经有了合约执行的环境参数,但仍然缺少合约执行时机器状态的定义,我们将其定义为。它包含了下面几项内容:

  1. :当前剩余的gas,初始为函数的参数
  2. 𝟚𝟝𝟞:指令计数器,指向即将执行的下一条指令,初始为0
  3. :内存,内存大小为,其中所有值初始化为0
  4. :内存中活跃的数量,其中活跃字指内存中被取出过的字和被存入的字,初始为0。仅仅是一个约数,它的值是从0地址到当前拥有最高地址的活跃字之间可能存在的活跃字的最大数量。详见黄皮书附录H中的MLOAD指令。
  5. :栈空间,其中总是栈顶,初始为空
  6. 当发生合约间调用时,用于保存callee的返回值,初始为空(只有CALL以及CREATE系列指令会设置这个值,CALL指令会将其设置为callee的返回值,CREATE指令会将其设置为,也许是因为CREATE指令调用的init代码返回的总是要进行部署的合约代码,保存它没有意义)

注意机器状态以及世界状态在合约执行过程中是不断更新的,每一条指令都对应一个状态转移函数,应用该函数将会导致机器/世界状态的更新。 有了上述定义后,我们可以定义:

注意上面的定义中的每一项被初始化为前面提到的初始值。是一个迭代执行函数,该函数的作用从整体上来看就是依次执行合约中的每一条指令,直到执行流程正常终止或者遇到异常: 在定义之前,我们首先需要研究下面几个问题:如何改变;程序何时终止;如何执行每一条指令。

  1. 我们定义为接下来将要执行的指令(指令寄存器越界时默认为

    可以看到我们需要考虑到正常执行和分支指令的情况,具体细节不再赘述。

  2. 我们将正常的停止状态定义为,合约停止时,该函数的值便是合约代码的返回值:

    注意当合约代码正常执行时,该函数的返回值为空集。

  3. 最后,我们可以给出每一条指令的执行函数的定义:

    其中是一个gas费函数,用于计算当前指令执行后需要花费的gas,详细定义较为复杂,不再赘述。 以及定义了分支指令以及正常执行情况下下一条指令的地址。 注意上面的定义仅仅是一个“基础”定义,它仅仅规定了必须进行的操作:栈的变化,gas费的扣除,PC的转移。 具体指令的执行仍然可能改变其他的机器状态,世界状态以及累积状态。每个指令拥有自己的形式化定义的状态转移函数,详见黄皮书附录H。 完成了对每一条指令的执行的形式化定义后,我们最终可以定义整个合约代码的执行函数:

可以看到,该函数迭代的执行合约中的每一条指令。注意当合约执行出错或者revert时,得到的世界状态为

最终函数将会对的返回值进行检查,并返回相应的世界状态,剩余gas,累积状态,状态码以及合约返回值 。 若返回的世界状态为,说明我们应当进行回滚,此时与输入的相同,状态码置为0,至于剩余gas ,我们需要分两种情况:

  1. 若该异常是已定义行为(即合约检测到错误主动引起的revert,此时合约输出),那么
  2. 若该异常为未定义行为,即。我们将取走所有的gas,即

当交易成功时,函数将对应的值返回即可,不再赘述。

#收尾工作

回顾前面的定义,对于message call的情形,我们可以看到调用链。 其中返回了合约执行后的世界状态,剩余gas,累积状态以及状态码

注意返回值被略去了,因为交易不需要知道合约返回值,返回值仅用于合约间函数调用,如果需要在交易中得到函数返回值,可以将其作为事件log出来,这些log会进入累积状态,最终随着交易收据返回。

合约执行完毕后,我们只需做一些收尾工作,如返还剩余的gas,删除死账户等操作即可。这些在黄皮书中有详细的定义,这里不再赘述。

#合约间函数调用

黄皮书中同样规定了合约间函数调用的流程,我们接下来对这一部分内容进行讨论。

#CALLSTATICCALL

当执行CALL指令时,我们实际是在调用其他合约的代码,根据之前的描述,我们仍然需要使用函数。 CALL指令总共有6个参数,前两个参数指定了函数调用的目标账户以及转账金额,中间两个参数指定了调用的calldata 在内存中的位置,后两个参数指定了返回值的保存地址以及返回值的最大大小。

接下来我们通过CALL指令的形式化定义来学习它的行为:

注意我们略去了一些关于gas和返回值等的内容。这里的返回值和之前提到的不太一样,怀疑是黄皮书的问题,已提PR #841

首先可以看到:

  1. callee的调用者被设置为,即当前执行合约的账户
  2. 交易的创造者为,保持不变
  3. 交易的接受者和代码的持有者都被设置为CALL指令的参数

使用函数对目标合约进行求值后,世界状态以及累积状态更新,同时将返回值保存到当前机器状态的一项中。如果我们后面调用了RETURNDATASIZE或者RETURNDATACOPY指令,它们会从获取之前合约间调用的返回数据。

注意对函数的递归调用同样属于交易函数,即合约间的函数调用不会创建新的交易。

CALL函数在安全性上仍然有不足之处,我们无法对被调用者的权限作出进一步的限制,例如限制对状态的修改。这带来的问题是我们无法对进行CALL调用之后的世界状态作出任何保证。 因此EIP-214添加了一个新的指令STATICCALL,该指令的定义与CALL的几乎相同,除了调用

  1. 第9和第10个参数转账金额和执行环境金额被设置为0
  2. 最后一个参数权限被设置为,即没有任何修改权限

同时,STATICCALL是具备传染性的,即被STATICCALL调用的代码的所有子调用都是STATIC的。

#DELEGATECALLCALLCODE

这里我们重点讨论DELEGATECALL,该指令深刻体现了以太坊计算架构与传统冯诺依曼架构的不同之处。

我们首先思考现有的合约部署的一个巨大的痛点:由于区块链的不可篡改性,我们无法修改一个已经部署了的合约。 假如我们的一个大型DApp的一小部分出现了问题,我们将不得不重新部署整个DApp。这将带来巨大的开销,同时用户也因此始终无法得到一个稳定的接口地址。

回顾以太坊的计算架构,我们可以发现它采用的是一种将存储和代码分离的哈佛架构。这带来的好处是我们可以在同一块存储空间上运行不同的代码。 结合以太坊本身的特性,我们考虑下面的设计:

我们将实际的处理逻辑切分为提供了不同接口的模块,同时将这些模块和实际存储空间解耦。这样我们在更新合约时就可以只更新相应的模块,然后将新的合约注册到用户接口合约中即可。

在仔细思考后我们可以提出下面两个需求;

  1. 我们需要一种合约调用方式,使被调用者在调用者的账户存储上代理执行
  2. 我们不希望主合约和模块之间发生转账,因为它们在逻辑上是统一的一个整体

有了上面的需求后,代理调用指令DELEGATECALL就应运而生了,它的形式化定义如下:

现在回过头看之前提出的需求:

    • 函数的参数合约调用者被设置为,保证调用前后的执行环境参数中保持不变。(msg.sender)
    • 合约接受者被设置为,该参数调用前后同样保持不变,由于SLOAD, SSTORE等指令通过访问当前账户的存储,这意味着执行合约使用的账户保持不变。
    • 拥有将要被执行的代码的账户被设置为,表示将要执行的代码取自账户
    • 执行环境中的value 被设置为,保证调用前后执行环境参数中的不变。(msg.value)
  1. 转账金额被设置为0,表示没有发生任何转账行为

    看到这里,我们可以发现函数中参数的区别:前者会被真正的转账到目标账户,后者仅仅存储发生交易的金额,用于初始化环境参数。 可以说,这一对参数就是专门为解决代理执行时的一致性问题而设计的。

我们可以看到,DELEGATECALL保证了调用前后的执行环境的一致性,从而使其他合约的代码逻辑也能无缝衔接到当前合约的执行过程中。

参考这篇文章,下面的代码很好的阐述了使用DELEGATECALL进行代理执行的思想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// https://fravoll.github.io/solidity-patterns/proxy_delegate.html
// This code has not been professionally audited, therefore I cannot make any promises about
// safety or correctness. Use at own risk.
contract Proxy {

address delegate;
address owner = msg.sender;

function upgradeDelegate(address newDelegateAddress) public {
require(msg.sender == owner);
delegate = newDelegateAddress;
}

function() external payable {
assembly {
let _target := sload(0)
calldatacopy(0x0, 0x0, calldatasize)
let result := delegatecall(gas, _target, 0x0, calldatasize, 0x0, 0)
returndatacopy(0x0, 0x0, returndatasize)
switch result case 0 {revert(0, 0)} default {return (0, returndatasize)}
}
}
}

注意这种设计模式可能导致安全性问题,参见这篇文章,攻击者利用DELEGATECALL调用无意间暴露出的修改钱包所有者的逻辑完成了一起大型盗窃。

至于CALLCODE指令,它的出现比DELEGATECALL要早,但是这个指令并没有实现将msg.sendermsg.value传递到callee执行环境中的机制。 因此在EIP-7中,DELEGATECALL被提出,作为CALLCODE的一个加强版。

同时,目前社区中也有将CALLCODE移除的呼声,参见EIP-2488

#Message Call与ABI

可以看到,黄皮书为我们规定了Message Call的大框架,但它同时也给了上层设施很高的自由度。我们可以将黄皮书为我们搭建的底层设施总结为:

执行Message Call对应的合约时,中的代码从0地址开始执行,同时拥有确定的初始执行环境

不难看出,黄皮书并没有出现合约内函数调用的实现,或者如何调用solidity合约中的函数诸如此类的规定。 这就像是一个规定了基本spec的硬件,仍然缺乏让普通程序员也能愉快编程的抽象。这一层抽象被称为ABI(Application Binary Interface)。接下来我们将会对着一部分内容进行研究。

我们主要需要解决的问题是函数分发。即如何调用合约内定义的函数。

我们以下面的合约为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract Dispatch {
uint256 counter;

function p1() public {
counter += 1;
}

function p2(uint256 a, uint256 b) public {
counter += a * b;
}

function p5() public returns (uint256) {
counter += 5;
return counter;
}
}

考虑我们目前已有的条件:

  1. 合约执行时一定从0地址开始执行
  2. 合约执行初始拥有确定的环境参数,如保存输入数据的

那么我们可以考虑创建一个分派器函数,该函数总是位于0地址处, 同时为每个函数设定签名,当我们调用某个函数时,我们发送的数据中包含该函数的签名,分派器函数进一步调用该函数。

使用solc编译上述合约后再对其进行反编译,可以看到该分配器函数(函数选择器)的全貌:

可以看到,该选择器的主要逻辑是根据calldata的前四个字节进行函数分派。这四个字节被称为function selector, 是通过函数签名(包括函数名,参数列表)进行keccak256哈希计算出来的,当我们进行函数调用时,发送的data实际上是 函数签名+参数,具体的编码形式参见官方的ABI文档.

正因为该ABI的存在,当我们调用链上合约时,我们需要知道一份对应的JSON文件,该文件描述了用于和该合约交互的ABI。该文件同样可以通过 solc编译得到,例如例子中的合约ABI表示为JSON为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[
{
"inputs": [],
"name": "p1",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "a",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "b",
"type": "uint256"
}
],
"name": "p2",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "p5",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
]

最后我们还遗留了一些其他问题,例如合约内函数调用,目前没有找到合适的资料,这里有我的一些个人的总结。 除此以外,还有一些涉及底层的,如变量的内存布局,可以参考官方文档

注意合约内的函数调用是通过JUMP来进行的,因此其传参方式与合约间调用不同。合约间调用时函数参数在calldata中,如果一个函数需要既支持合约内函数调用,又要支持合约间函数调用, 它在处理参数时就需要先把参数保存到内存中,然后再做进一步的处理,这样的函数使用public修饰符来修饰。而只能进行合约间调用的函数可以直接从calldata中获取参数,这样的函数 使用external修饰符来修饰。由于内存的使用是十分昂贵的,在参数相同,逻辑相同的情况下,调用public方法会付出比external方法更多的gas费。

#捐赠

如果本文为你带来了帮助,可以向m4tsuri.eth转账进行捐赠:)


← Prev 经济学基础概念 | 详解以太坊Merkle Patricia Tree Next →