概述
前往我的博客获得更好地阅读体验。
本文主要介绍最小化代理合约EIP1167
的相关内容。为了实现最小化,EIP1167
使用了bytecode
(字节码)作为主要编码方式,即直接使EVM
汇编指令进行编写。本文将在openzeppelin
提供的合约基础上,为读者逐个字节码的解析EIP1167
,帮助读者理解EVM
底层汇编和EIP1167
的实现原理。
注意虽然EIP1167
也实现了代理合约,但其不具有合约升级能力,如果你希望构造可升级合约,请阅读以下文章:
如果读者没有代理合约开发经验,也建议阅读上文获得一些关于代理合约的基本知识。
建议读者在阅读后文之前可以简单读一下EIP1167标准。
openzeppelin实现
我们在此处首先给出openzeppelin
的合约实现,代码如下:
function clone(address implementation) internal returns (address instance) {
/// @solidity memory-safe-assembly
assembly {
let ptr := mload(0x40)
mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(ptr, 0x14), shl(0x60, implementation))
mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
instance := create(0, ptr, 0x37)
}
require(instance != address(0), "ERC1167: create failed");
}
上述代码描述了代理合约生成的基本结构。我们采用从顶向下分析的方法,首先关注合约生成的核心代码instance := create(0, ptr, 0x37)
。查阅EVM
汇编表格,我们可以知道此函数接受三个变量,分别是:
- value, 传递给新合约的ETH(以
wei
计费) - offset, 新合约代码在内存中的起始位置
- size, 新合约的代码长度
本质上来说,此函数实现获取内存中的合约代码并将其进行部署的功能。在此处,我们没有向新合约传递ETH,规定了新合约的代码在内存中的起始位置为ptr
,长度为0x37 byte
,即55 byte 或 110 个16进制数字。
我们可以断定以下代码的功能是构造新合约的字节码:
let ptr := mload(0x40)
mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(ptr, 0x14), shl(0x60, implementation))
mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
正如前文所述,由于EIP1167
完全使用字节码编程,而solidity
对内存级控制并不擅长,所以我们在此处只能提供内联汇编实现代码构建。当然,对于一般的solidity
合约,你可以参考此文。
接下来,我们对字节码构造部分进行解释,注意在此节中,我们不会指明生成的字节码的作用,此部分内容位于下一节。
此处用到了implementation
变量,即需要被代理合约的地址,我们在此处假设其值为0xbebebebebebebebebebebebebebebebebebebebe
。
let ptr := mload(0x40)
。此处对ptr
的值进行初始化。初始化的方法是读取(使用mload函数读取指定地址的值) 0x40 地址的值。此处使用0x40
地址进行读取的原因是此地址内存储着空闲内存的起始位置。在此处举一个例子,如果你的合约已经把0x60
前的内存都填满了,读取0x40
位置时,会获得0x61
这个值。使用0x40
中存储的地址可以有效避免内存覆写冲突问题的出现。
实际上
mload(0x40)
返回的是内存目前的占用量,其等同于空闲内存的起始位置,具体可以参考Layout in Memory
当我们获取到空闲内存的起始位置后,我们接下来就可以构造EIP1167
合约的字节码。
代码中各个汇编函数的作用如下:
mstore(offset, value)
的作用为向指定内存地址内写入value
数据。注意offset
的单位为byte
且value
的长度必须为32 byte
add(a, b)
的作用为a + b
shl(shift, value)
的作用为将value
左移shift
个bit
。注意单位为bit
综合以上内容,我们可以得到每行代码的具体作用。
mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
,我们首先在ptr
后插入了0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000
(32 byte)。
mstore(add(ptr, 0x14), shl(0x60, implementation))
,我们首先将implementation
的地址(20 byte)通过shl
左移0x60 bit
,即12 byte
,形成32 byte
的标准数据。得到标准数据后,我们将数据写入ptr + 0x14
处,即ptr
后20 byte
(40个16进制数),最终形成以下数据:
0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe000000000000000000000000
最后,我们通过mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)
写入数据,形成以下数据:
0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
根据上文给出的create
的参数,我们发现部署合约时仅读取此字节码的前0x37 byte
的数据,即使用以下数据构造合约:
0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
关于此此字节码的作用,我们会在下文进行解释。
上述流程可以用下图进行概括:
此图展示了上述汇编代码对内存的修改情况。其中最上方的ptr
、ptr + 0x14
、ptr + 0x28
等值表示当前的内存地址,0x14
等值的单位均为byte
。
字节码解析
运行流程
在进行字节码分析前,我们需要在顶层理解EIP1167
是如何运行的,其核心在于delegatecall
的使用。
合约运行分为以下几个步骤:
- 获得
calldata
,用户发送的calldata
中包含需要调用的函数和对应的参数,我们需要获得calldata
以便于后期进行转发。 - 使用
delegatecall
发送calldata
。合约在获得calldata
后可以通过delegatecall
进行委托调用,代理合约会把被代理合约内的代码拉取到本地输入calldata
进行运行,并将结果保存到代理合约内。 - 获得
delegatecall
返回的结果并并储存到内存中 - 向用户返回结果或错误
以上就是EIP1167
的运行流程,接下来我们会解释如何通过0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3
字节码实现这一功能。
初始化
我们将智能合约分为两部分,一部分是在创建合约时运行的代码,我们称为创建代码(creation code 或 Deploy code),另一部分则是逻辑代码(runtime code)。
前者主要实现以下功能:
- 运行
constructor
构造器函数 - 进行合约变量初始化
- 将
runtime code
复制到内存中
一个比较好的类比是创建代码类似软件的安装包,它会根据用户的输入选择安装文件夹释放文件并进行软件的初始化。类比无法使我们接近本质,所以我们在此处给出go-ethereum
的合约创建源代码:
func (evm *EVM) create