概述

本文主要介绍最小化代理合约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的单位为bytevalue的长度必须为32 byte
  • add(a, b)的作用为a + b
  • shl(shift, value)的作用为将value左移shiftbit。注意单位为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处,即ptr20 byte(40个16进制数),最终形成以下数据:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe000000000000000000000000

最后,我们通过mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)写入数据,形成以下数据:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000

根据上文给出的create的参数,我们发现部署合约时仅读取此字节码的前0x37 byte的数据,即使用以下数据构造合约:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

关于此此字节码的作用,我们会在下文进行解释。

上述流程可以用下图进行概括: EIP1167 Memory

此图展示了上述汇编代码对内存的修改情况。其中最上方的ptrptr + 0x14ptr + 0x28等值表示当前的内存地址,0x14等值的单位均为byte

字节码解析

运行流程

在进行字节码分析前,我们需要在顶层理解EIP1167是如何运行的,其核心在于delegatecall的使用。

合约运行分为以下几个步骤:

  1. 获得calldata,用户发送的calldata中包含需要调用的函数和对应的参数,我们需要获得calldata以便于后期进行转发。
  2. 使用delegatecall发送calldata。合约在获得calldata后可以通过delegatecall进行委托调用,代理合约会把被代理合约内的代码拉取到本地输入calldata进行运行,并将结果保存到代理合约内。
  3. 获得delegatecall返回的结果并并储存到内存中
  4. 向用户返回结果或错误

以上就是EIP1167的运行流程,接下来我们会解释如何通过0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3字节码实现这一功能。

初始化

我们将智能合约分为两部分,一部分是在创建合约时运行的代码,我们称为创建代码(creation code 或 Deploy code),另一部分则是逻辑代码(runtime code)。

前者主要实现以下功能:

  • 运行constructor构造器函数
  • 进行合约变量初始化
  • runtime code复制到内存中

一个比较好的类比是创建代码类似软件的安装包,它会根据用户的输入选择安装文件夹释放文件并进行软件的初始化。类比无法使我们接近本质,所以我们在此处给出go-ethereum的合约创建源代码:

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
	// Depth check execution. Fail if we're trying to execute above the
	// limit.
	if evm.depth > int(params.CallCreateDepth) {
		return nil, common.Address{}, gas, ErrDepth
	}
	if !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {
		return nil, common.Address{}, gas, ErrInsufficientBalance
	}
	nonce := evm.StateDB.GetNonce(caller.Address())
	if nonce+1 < nonce {
		return nil, common.Address{}, gas, ErrNonceUintOverflow
	}
	evm.StateDB.SetNonce(caller.Address(), nonce+1)
	// We add this to the access list _before_ taking a snapshot. Even if the creation fails,
	// the access-list change should not be rolled back
	if evm.chainRules.IsBerlin {
		evm.StateDB.AddAddressToAccessList(address)
	}
	// Ensure there's no existing contract already at the designated address
	contractHash := evm.StateDB.GetCodeHash(address)
	if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != emptyCodeHash) {
		return nil, common.Address{}, 0, ErrContractAddressCollision
	}
	// Create a new account on the state
	snapshot := evm.StateDB.Snapshot()
	evm.StateDB.CreateAccount(address)
	if evm.chainRules.IsEIP158 {
		evm.StateDB.SetNonce(address, 1)
	}
	evm.Context.Transfer(evm.StateDB, caller.Address(), address, value)

	// Initialise a new contract and set the code that is to be used by the EVM.
	// The contract is a scoped environment for this execution context only.
	contract := NewContract(caller, AccountRef(address), value, gas)
	contract.SetCodeOptionalHash(&address, codeAndHash)

	if evm.Config.Debug {
		if evm.depth == 0 {
			evm.Config.Tracer.CaptureStart(evm, caller.Address(), address, true, codeAndHash.code, gas, value)
		} else {
			evm.Config.Tracer.CaptureEnter(typ, caller.Address(), address, codeAndHash.code, gas, value)
		}
	}

	start := time.Now()

	ret, err := evm.interpreter.Run(contract, nil, false)

	// Check whether the max code size has been exceeded, assign err if the case.
	if err == nil && evm.chainRules.IsEIP158 && len(ret) > params.MaxCodeSize {
		err = ErrMaxCodeSizeExceeded
	}

	// Reject code starting with 0xEF if EIP-3541 is enabled.
	if err == nil && len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon {
		err = ErrInvalidCode
	}

	// if the contract creation ran successfully and no errors were returned
	// calculate the gas required to store the code. If the code could not
	// be stored due to not enough gas set an error and let it be handled
	// by the error checking condition below.
	if err == nil {
		createDataGas := uint64(len(ret)) * params.CreateDataGas
		if contract.UseGas(createDataGas) {
			evm.StateDB.SetCode(address, ret)
		} else {
			err = ErrCodeStoreOutOfGas
		}
	}

	// When an error was returned by the EVM or when setting the creation code
	// above we revert to the snapshot and consume any gas remaining. Additionally
	// when we're in homestead this also counts for code storage gas errors.
	if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) {
		evm.StateDB.RevertToSnapshot(snapshot)
		if err != ErrExecutionReverted {
			contract.UseGas(contract.Gas)
		}
	}

	if evm.Config.Debug {
		if evm.depth == 0 {
			evm.Config.Tracer.CaptureEnd(ret, gas-contract.Gas, time.Since(start), err)
		} else {
			evm.Config.Tracer.CaptureExit(ret, gas-contract.Gas, err)
		}
	}
	return ret, address, contract.Gas, err
}

上述代码的核心为:

ret, err := evm.interpreter.Run(contract, nil, false)

此行代码将合约字节码进行了运行,我们再次给出合约字节码:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

当我们进行合约运行时,EVM解释器会从头进行运行字节码,在此处给出字节码代表的操作码:

[00]	RETURNDATASIZE	
[01]	PUSH1	2d
[03]	DUP1	
[04]	PUSH1	0a
[06]	RETURNDATASIZE	
[07]	CODECOPY	
[08]	DUP2	
[09]	RETURN

完整的字节码结果可以参考这里

我们仅给出了3d602d80600a3d3981f3代表的代码,因为RETURN操作码会中止合约运行。在创建过程中,我们仅会运行上述9个操作码。

操作码编号操作码名称操作码作用运行后的堆栈情况运行后的内存
3dRETURNDATASIZE向堆栈中写入CALLDELEGATECALL的返回值的长度0-
602dPUSH1 2d向堆栈中的第一个位置推入2d2d 0-
80DUP1复制堆栈中的第一个值2d 2d 0-
600aPUSH1 0a向堆栈中的第一个位置推入0a0a 2d 2d 0-
3dRETURNDATASIZE同上0 0a 2d 2d 0-
39CODECOPY读取堆栈中的数据依次作为代码写入内存的起始位置、代码起始的读取位置和读取的代码长度2d 0[0-2d]:runtime code
81DUP2复制堆栈中第2个元素推入堆栈0 2d 0[0-2d]: runtime code
f3RETURN在堆栈中依顺序读出元素作为返回值的内存起始位置和长度0[0-2d]: runtime code

如果读者不太理解上述的操作码的含义,可以参考EVM Codes查询操作码的参数和功能。

读者可能发现我们使用RETURNDATASIZE进行了堆栈操作,而在此处写显然没有任何CALL之类的操作。我们在此处使用RETURNDATASIZE的作用只是向堆栈中写入0。使用PUSH1 0写入0会消耗3 gas,而使用RETURNDATASIZE写入则仅消耗2 gas。后者更节省gas费用。

EVM运行完上述代码后,go-ethereum客户端会把返回的字节码存储到ret变量中,通过evm.StateDB.SetCode(address, ret)将合约地址和合约代码保存到StateDB数据库中,在我们后期进行调用时,仅运行EVM返回的以下字节码:

363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

接下来我们会介绍上述runtime代码的具体功能。

获取Calldata

正如本节概述,我们进行合约代理的第一步是获得用户向合约发送的calldata,使用的字节码为363d3d37

获得calldata的操作码为CALLDATACOPY,要求以下参数:

  • destOffset, 将calldata复制到内存中的起始位置
  • offset, 需要复制的calldata的起始位置
  • size, 需要复制的calldata的长度

在此处,我们将calldata整体进行复制到内存中,具体操作如下表:

操作码编号操作码名称操作码作用运行后的堆栈情况运行后的内存
36CALLDATASIZE获得calldata的长度cds-
3dRETURNDATASIZE如前文所述,一种向堆栈中推入0的廉价方式0 cds-
3dRETURNDATASIZE同上0 0 cds-
37CALLDATACOPY如前所述复制calldata到内存-[0-cds]Calldata

在上文给出的流程中,我们仍使用了RETURNDATASIZE向堆栈中填入0 。

另一点需要注意的是栈属于后进先出(LIFO)的数据类型,所以我们需要先推入size参数再推入offset最后推入destOffset参数。

Stack Picture

经过以上流程,我们成功把calldata复制到内存中,下一步则需要使用calldata进行delegatecall

Delegatecall

此过程对应3d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af4字节码。

此流程最核心的操作码为DELEGATECALL,所需参数如下:

  • gas, 委托调用所需要的gas费用
  • address, 委托调用目标合约地址
  • argsOffset, 委托调用所需要的calldata在内存中的起始位置
  • argsSize, calldata的长度(以byte计)
  • retOffset, 返回值在内存中存储的起始位置
  • retSize, 返回值长度(以byte计)

在运行完成后,此操作码会向堆栈推入1(运行成功)或0(运行失败)。

此处我们介绍用于辅助的操作码GAS,该操作码不需要读取堆栈而直接向堆栈内推入gas数据。

根据我们目前的内存情况和目标,我们应该构建以下堆栈以供DELEGATECALL调用:

[ GAS | address | 0 | cds | 0 | 0 ]

此处把retOffsetretSize设置为0的原因是我们在此处无法知道返回值的长度,所以将其设置为0。但设置为0并不意味着我们读返回值,返回值仍会被保存在return data的特殊区域,我们会在下一环节读取返回值。

EVM中,存储区域有代码存储区域、堆栈、内存、存储、CallData存储和Return data存储区域。我们在上文中,由于不清楚返回值的情况,所以通过设置retOffsetretSize为0,阻止了返回值直接写入内存,在后文我们会在Return data中提取返回值。

更多关于EVM存储可以参考此网站

我们依旧采用表格的方式逐步分析字节码:

操作码编号操作码名称运行后的堆栈情况
3dRETURNDATASIZE0
3dRETURNDATASIZE0 0
3dRETURNDATASIZE0 0 0
36CALLDATASIZEcds 0 0 0
3dRETURNDATASIZE0 cds 0 0 0
73bebebebebebebebebebebebebebebebebebebebePUSH20 bebe…addr 0 cds 0 0 0
5aGASgas addr 0 cds 0 0 0
f4DELEGATECALLsuccess 0

*由于此处不涉及内存读写,所以我们删除了此列。同时考虑到读者可以理解此列表中大大部分操作码,所以也删掉了"操作码作用"一列

读者可能发现了此处我们在堆栈中多写入了一个0,这是因为delegatecall后,我们无法再通过RETURNDATASIZE操作码以廉价的方式写入0,我们在此处多填入一个0以方便后期使用。

获取Returndata

此流程对应的字节码为3d82803e

核心操作码为RETURNDATACOPY,所需参数为:

  • destOffset, Returndata复制到内存中的起始位置
  • offset, 需要复制的Returndata的起始位置
  • size, 需要复制的Returndata的长度

当然,此处主要使用的辅助操作码为DUPn(其中n∈[1, 16]),其主要为将堆栈中的第n个元素复制并推入堆栈。如目前堆栈中存在0 1两个元素,使用DUP2后运行完后堆栈为1 0 1,即将第二个元素1复制并推入堆栈。

在此处我们给出分析表格:

操作码编号操作码名称运行后的堆栈情况内存
3dRETURNDATASIZErds success 0[0-cds]Calldata
82DUP30 rds success 0[0-cds]Calldata
80DUP10 0 rds success 0[0-cds]Calldata
3eRETURNDATACOPYsuccess 0[0-rds]Returndata

在此过程中,我们完成将返回值复制进入内存,在下一步中,我们会真正把返回值或错误返回给用户。

返回

此流程对应操作码为903d91602b57fd5bf3

在返回值之前,我们需要判断success的值,如果此值为1,说明delegatecall成功,我们以正常形式返回内存中的结果; 如果此值为0,说明delegatecall失败,我们则使用REVERT,以错误信息的形式返回内存中的值。

上述过程依赖于JUMPI操作码,此操作码接受以下参数:

  • counter, 需要跳转的代码位置
  • b, 若b不为0则进行跳转,否则则不跳转继续运行。

JUMPI对应的跳转位置需要存在JUMPDEST操作码标识跳转位置。

此处所使用的另两个重要操作码RETURNREVERT所需参数相同,均为:

  • offset, 返回值在内存中存储的起始位置
  • size, 返回值的长度

注意这两个操作码一旦运行则标志合约运行的结束。EVM读取到这两个操作码后,不会再继续运行。

为了方便读者理解跳转关系,我们使用一下图像:

|           0x00000024      90             swap1                 0 suc
|           0x00000025      3d             returndatasize        rds 0 suc
|           0x00000026      91             swap2                 success 0 rds
|           0x00000027      602b           push1 0x2b            0x2b success 0 rds
|       ,=< 0x00000029      57             jumpi                 0 rds
|       |   0x0000002a      fd             revert
|       `-> 0x0000002b      5b             jumpdest              0 rds
\           0x0000002c      f3             return

上图来自EIP1167文档

第一列为操作码的位置,第二列为操作码的编号,第三列为操作码名称,最后一列为堆栈情况。

为了最后进行值的返回,我们首先构造通过一系列操作将堆栈中最后两个元素设置为0 rds,方便RETURNREVERT操作码使用。同时也构造了0x2b success作为JUMPI所需要的参数。图中也表示了不同的跳转方式,当success不为0时,即delegatecall运行成功,代码跳转到0x2b运行执行RETURN,返回正常值。如果success为0,则执行revert,将返回值作为异常抛出。

总结

本文主要介绍了以下内容:

  • openzeppelinclone函数生成字节码的过程
  • go-ethereum创建智能合约的源代码
  • EIP1167字节码具体运作流程

除此之外,我们还介绍EVM运行环境的基本情况和常见字节码的含义。

读者可以阅读EIP1167文档中给出的流程图进一步理解字节码运行流程。