OneSwap系列十三不像的delegatecall

1,106 阅读13分钟

对于程序员而言,对于以下这四种操作,都觉得很稀松平常:

  1. 给程序创建一个符号链接(Linux)或者快捷方式(Windows),点击这个符号链接或者快捷方式,即可启动程序
  2. 使用动态链接库,减小可执行文件的大小
  3. 定期升级升级操作系统和应用程序,增强功能,打安全性补丁
  4. 在一个脚本当中source或者exec另外一个脚本,并且用环境变量给它传递信息

这些操作看起来互不相关,但是在Solidity当中,要达到类似它们的效果,居然是通过同一种机制:delegatecall。

Solidity当中的delegatecall是如此之特殊,以至于在其他编程语言当中找不到和它直接对应的概念。程序员理解它的时候,也存在一些困难,本文中,我们用上面四种操作做类比,希望可以帮助您理解它。

调用合约的4种机制

之前的文章中,我们介绍了Solidity的底层函数调用机制,即直接对合约地址使用call函数,它对应于EVM底层的call指令。其含义这里不再赘述。

EVM还支持另外三条指令,用来进行合约间调用,它们分别是:staticcall、delegatecall和callcode。

staticcall和call非常类似,只不过被调用的合约的持久化状态存储将被设置为只读的,无法修改。Solidity用它来调用那些具有view和pure属性的external函数。

delegatecall非常特殊,A合约调用B合约时,B所读写持久化状态存储其实是在A合约里面的,或者说,A把自己的存储授权给了B,请它来修改。

callcode早已不推荐使用,它非常类似delegatecall,但是没有保留msg.sender和msg.value,算是一个被设计得有Bug的EVM指令。delegatecall指令被引入EVM,就是为了修正这个bug。

智能合约的快捷方式

假设在一台多个用户共享的PC上,用户Alice在D盘安装了一个消消乐游戏,用户Bob也想玩,那么他可以选择:

  1. 把这个程序拷贝一份到自己的目录下来玩,这样会占用一些硬盘空间
  2. 创建一个桌面快捷方式,指向这个消消乐游戏的可执行文件,这样几乎不会占用硬盘空间

类似地,在以太坊上,用户A部署了一个智能合约,用户B想部署一个一模一样的合约,那么他可以选择:

  1. 把A部署的合约的字节码下载下来,然后自己再部署一份
  2. 部署一个功能类似快捷方式的合约,指向A部署的合约

以太坊的EIP-1167规定了这种类似快捷方式的合约应该怎么写,它在使用EVM指令编写合约时用了一些技巧,无论从字节数还是Gas消耗而言,都做到了最省。为了清晰,我们不逐一介绍它的字节码,而是换一种更清晰的表述来介绍它做了什么操作。

            let ptr := mload(0x40)
            let size := calldatasize()
            calldatacopy(ptr, 0, size)
            let result := delegatecall(gas(), impl, ptr, size, 0, 0)
            size := returndatasize()
            returndatacopy(ptr, 0, size)
            switch result
            case 0 { revert(ptr, size) }
            default { return(ptr, size) }

这段代码完成的任务同EIP-1167一样,但是可读性更好。ptr指向可分配的空闲内存的起始位置,这个位置被Solidity保存在地址0x40处。size为本合约的calldata的长度,calldatacopy把本合约的calldata拷贝到空闲内存当中。delegatecall使用内存中从ptr开始,长度为size的数据(这段数据就是刚刚被拷贝过来的calldata),去调用impl这个合约。delegatecall的第一个参数是gas(),表示说把当前合约剩余的Gas都交给被调用的合约了。后面的returndatacopy则把被调用合约的返回值拷贝到空闲内存中。最后的switch,根据被impl合约是否被成功调用(delegatecall返回的bool值result是True还是False),来决定当前合约是用return结束还是用revert结束,用return结束表示当前合约执行成功了,用revert表示不成功。无论是否成功,都把刚才拷贝到内存中的returndata,返回给当前合约的调用者。从整个过程我们可以看出:

  1. 外部用怎样的calldata来调用本合约,本合约就用怎样的calldata来调用impl
  2. Impl成功return,则本合约也成功return;impl若revert来,本合约也revert
  3. impl返回给本合约的returndata,本合约原封不动地返回给本合约的调用者

从外部看来,调用这个合约,和调用impl的效果是完全一样的,只是因为多了一些操作,Gas略微上升了。

EIP-1167所优化的“快捷方式合约”,体积非常小,只有90个字节,部署它的成本非常低。

这里再提一个小细节:delegatecall的最后两个参数是做什么用的?是用来完成returndata的拷贝的,如果你在调用之前就确定了returndata的大小,可以填充这两个参数来完成拷贝,就不需要专门调用returndatacopy了。

不太完美的动态链接库

动态链接库,即Windows的.dll文件和Linux的.so文件,可以让很多程序共享同样的库代码,避免把一份库代码拷贝在每一份可执行文件中都包含一份,从而造成体积膨胀。可执行文件一旦链接了动态链接库,则库中的函数就和它共享进程的内存空间和权限,我们可以把一个数组的起始地址交给库函数来请它对其排序,或者把一个目录的名字告诉库函数请它对里面的文件进行批量压缩。

在以太坊上,对于减小智能合约体积的需求更加的迫切。因为自从EIP-170被使能以来,一个合约的字节码的长度,就不能超过24576字节。OneSwapPair.sol仅仅有1200多行,它编译出来的OneSwapPair字节码长度就已经达到了17882,可以想象稍微复杂一些的合约,几乎肯定会超过24576字节。

于是,Solidity支持了Library,Library自己是个单独部署的合约,其中的external函数,供其他合约以delegatecall的方式来调用。这样其他合约就可以减小自己的体积了。

但可惜的是,delegatecall只能让被调用的库函数控制自己的存储空间,而无法做到让它访问自己的内存空间,有些像一个不完美的、不可共享内存空间而只能访问磁盘的动态链接库。在语法上,虽然Library中的external函数也可以接收memory类型的参数,如变长数组、字符串等,但这些参数是通过calldata的复制来传值的,而不能传一个指针(或引用),因为被调用的函数完全在另外一个EVM的内存空间中工作。例如下面的函数:

    function findMin(uint[] memory arr) external returns (uint) {
	    uint min = 0;
	    for(uint i = 0; i < arr.length; i++) {
		    if (i < min) min = i;
	    }
	    return min;
    }

它如果是作为一个库函数被调用的话,整个arr必须从calldata复制过去,导致的Gas消耗非常高:虽然数据被拷贝进入calldata不收Gas,但把库函数把数据从calldata拷贝到memory当中要消耗Gas。相对于代码体积的一点点缩小,高昂的Gas很可能让你得不偿失。

因此,Solidity当中的Library,最好只包含internal的函数,这些函数会被复制进你部署的合约中,类似静态链接库,这样可以减少Gas消耗。OneSwap项目中的的Library就是这样设计的。

库函数接受storage类型的函数时,的确采用了传引用的方式。因此,如果上面的函数声明是function findMin(uint[] storage arr) external returns (uint) ,它就能以较低的Gas代价,实现一个动态链接库函数的效果。

支持动态升级的代理(proxy)

操作系统和应用程序在发布之后,会定期或不定期的升级,这对用户而言是一件习以为常的事情了。然而在以太坊上,智能合约一旦部署之后,就不能更新了,要想修正Bug或增强功能,只能另外部署一个合约。

为了能在新旧合约之间顺利迁移,达到一种类似于升级合约的效果,我们可以把刚才讲的“智能合约的快捷方式”加以扩展。如果被调用的合约地址impl不是一个在合约代码中写死的值,而是一个来自storage的变量,那么就可以通过修改storage变量来切换这个“快捷方式”所指向的合约了。在这种场景中,这个“快捷方式”合约,习惯上被称为代理(proxy)合约。

实现代理合约,需要小心的一点是,因为代理合约和最终被调用的合约共享持久存储的空间,不能让它们访问的storage变量所分配的Slot有冲突。为了避免冲突,我们往往把storage变量分成3类,它们的slot的编号依次递增。

  1. 第一类:只由代理合约访问的变量,最终被delegatecall的合约地址是什么,谁有权在什么情况下修改这个地址,这些逻辑需要一些读写一些storage变量才能正常工作
  2. 第二类:代理合约和被调用的合约都需要访问到的变量,主要是一些需要在构造函数中初始化的变量
  3. 第三类:只由最终被调用的合约访问的storage变量

第一类和第二类变量,需要在两个合约(代理合约和被调用的合约)中都声明,且数量顺序完全一致。第三类只需要在被调用的合约中声明。

在OneSwapPair.sol当中,OneSwapPairProxy和OneSwapPair最开始的几个Slot,定义了这些变量:

    uint internal _unusedVar0;
    uint internal _unusedVar1;
    uint internal _unusedVar2;
    uint internal _unusedVar3;
    uint internal _unusedVar4;
    uint internal _unusedVar5;
    uint internal _unusedVar6;
    uint internal _unusedVar7;
    uint internal _unusedVar8;
    uint internal _unusedVar9;
    uint internal _unlocked;

其中,_unlocked属于上述第二类变量,代理合约(OneSwapPairProxy)和被调用的合约(OneSwapPair)都需要访问它,OneSwapPairProxy负责在构造函数中给它赋初始值。

而前十个_unusedVar*变量,是上述第一类变量。它们虽然在OneSwapPairProxy和OneSwapPair中都没有被使用,但我们还是声明了它们,即预留出了0~9这几个Slot编号,以备未来需要使用这第一类变量。

OneSwapPairProxy为什么不需要第一类变量呢?因为它可以从它的创建者OneSwapFactory合约那里查询得到它应该调用哪个OneSwapPair合约:

        address impl = IOneSwapFactory(address(_immuFactory)).pairLogic();

采用这种方式,只要OneSwapFactory合约更新了pairLogic,所有交易对的OneSwapPairProxy合约都会查询到更新后的impl地址。

Immutable Forwarding

我们刚才提到过,delegatecall并没有完美地实现动态链接库的功能。一个合约用delegatecall调用另外一个合约,更像是在一个脚本当中source或者exec另外一个脚本,请它对磁盘上存储的文件做出一些修改。

当我们运行一个脚本的时候,有三种方式可以给它传递信息,来控制它的行为:命令行参数,配置文件,以及环境变量。

类比到delegatecall,proxy合约把参数原样传递给了被调用的合约,类似于用命令行参数来传递信息。而前面讲到的第二类storage变量,可以起到类似配置文件的作用,proxy合约给它们写入值,被调用的合约读取它们。之前的文章提到过,这种配置方法的缺点在于:storage变量的Gas消耗,比immutable变量大不少。

OneSwapPairProxy合约中使用immutable变量来保存针对不同交易对的配置信息,这些配置信息必须转发(Forward)给OneSwapPair合约,才能发挥作用。由于Proxy只能做到calldata整体打包转发,很难把这些信息通过OneSwapPair合约中函数的参数列表传递进去。为了节省Gas,我们又不想通过storage变量来传递配置信息。那应该怎么办呢?能不能模拟出环境变量的效果,从另外的途径把配置信息传递过去?

的确有这样的方法。之前我们提到过,calldata的尾部,如果有多余的数据,是ABI解析逻辑所不需要的,那么也不会报错;只有数据不足时才会报错。因此,我们可以把配置信息附加到calldata的后面,传递给OneSwapPair合约。在OneSwapPairProxy的代码中,是这样实现的:

    uint internal immutable _immuFactory;
    uint internal immutable _immuMoneyToken;
    uint internal immutable _immuStockToken;
    uint internal immutable _immuOnes;
    uint internal immutable _immuOther;

    constructor(address stockToken, address moneyToken, bool isOnlySwap, uint64 stockUnit, uint64 priceMul, uint64 priceDiv, address ones) public {
        _immuFactory = uint(msg.sender);
        _immuMoneyToken = uint(moneyToken);
        _immuStockToken = uint(stockToken);
        _immuOnes = uint(ones);
        uint temp = 0;
        if(isOnlySwap) {
            temp = 1;
        }
        temp = (temp<<64) | stockUnit;
        temp = (temp<<64) | priceMul;
        temp = (temp<<64) | priceDiv;
        _immuOther = temp;
        _unlocked = 1;
    }

    receive() external payable { }
    // solhint-disable-next-line no-complex-fallback
    fallback() payable external {
        uint factory     = _immuFactory;
        uint moneyToken  = _immuMoneyToken;
        uint stockToken  = _immuStockToken;
        uint ones        = _immuOnes;
        uint other       = _immuOther;
        address impl = IOneSwapFactory(address(_immuFactory)).pairLogic();
        // solhint-disable-next-line no-inline-assembly
        assembly {
            let ptr := mload(0x40)
            let size := calldatasize()
            calldatacopy(ptr, 0, size)
            let end := add(ptr, size)
            // append immutable variables to the end of calldata
            mstore(end, factory)
            end := add(end, 32)
            mstore(end, moneyToken)
            end := add(end, 32)
            mstore(end, stockToken)
            end := add(end, 32)
            mstore(end, ones)
            end := add(end, 32)
            mstore(end, other)
            size := add(size, 160)
            let result := delegatecall(gas(), impl, ptr, size, 0, 0)
            size := returndatasize()
            returndatacopy(ptr, 0, size)

            switch result
            case 0 { revert(ptr, size) }
            default { return(ptr, size) }
        }
    }

构造函数接收若干参数,把它们保存在immutable变量中。fallback函数中的汇编代码,大体结构同刚才的“快捷方式合约“很类似,它增加了一个end变量和一些mstore语句。这些mstore语句将immutable变量的值保存得到内存中,附加到calldata的后面,在delegatecall的时候,原本的calldata,连同附加的immutable变量的值,都被传递给了OneSwapPair合约。

OneSwapPair合约使用fill函数来将calldata尾部附加的信息复制到内存里的proxyData当中,之后,factory、money等函数进一步从proxyData中取出我们所需要的配置信息。

    function factory(uint[5] memory proxyData) internal pure returns (address) {
         return address(proxyData[INDEX_FACTORY]);
    }
    function money(uint[5] memory proxyData) internal pure returns (address) {
         return address(proxyData[INDEX_MONEY_TOKEN]);
    }
    function fill(uint[5] memory proxyData, uint expectedCallDataSize) internal pure {
        uint size;
        // solhint-disable-next-line no-inline-assembly
        assembly {
            size := calldatasize()
        }
        require(size == expectedCallDataSize, "INVALID_CALLDATASIZE");
        // solhint-disable-next-line no-inline-assembly
        assembly {
            let offset := sub(size, 160)
            calldatacopy(proxyData, offset, 160)
        }
    }

在fill函数中,还专门检查了附加上配置信息之后的calldata长度是否正好等于expectedCallDataSize,如果不是话,很可能是黑客构造了异常的calldata来调用OneSwapPairProxy合约,要加以防范。

总结

由于在其他编程语言中没有和EVM的delegatecall对应的概念,程序员理解起来有一定的困难。本文通过一些典型的用例来介绍delegatecall,希望能帮助您更深刻地理解它。