关于【可变合约】的二三事(下)

代理合约

delegatecall函数

要比较好地理解代理合约,必须对delegatecall函数有所了解。

delegatecall是solidity中比较底层的函数,其主要作用就是委托调用,在日常的业务开发中,其实用的比较少,与之类似的函数还有call函数,也是比较底层的函数,其作用是调用。

如果你当前不太理解,没有关系,我直接给一个具体的例子,让你比较好地理解delegatecall。

首先,在进行编码前,你需要知道delegatecall的语法:目标合约address.delegatecall(合约方法对应的二进制编码)。

怎么获得二进制编码?

这需要理解Solidity ABI(Application Binary Interface,应用二进制接口)的概念。ABI是我们与ETH合约交互的标准,Solidity中提供了4个与ABI编码相关的函数,分别是:abi.encode, abi.encodePacked, abi.encodeWithSignature, abi.encodeWithSelector。

其中abi.encodeWithSignature函数获得的结果便是deletegatecall函数需要的二进制编码。ABI相关的细节,单独开一篇文章讨论,这里只需要了解abi.encodeWithSignature函数的使用方式,简单写个合约:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;


contract LearnAbiContract {
    uint256 x = 6;

    address account_addr = 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2;
    uint[] arr = [1,2,3,4,5];

    function encodeWithSignature() public view returns(bytes memory result) {
        result = abi.encodeWithSignature("function_name(uint256, address, uint[])", x, account_addr, arr);
    }

}

获得的结果如下:

39ce10b9a1a7edb112858451755c888b.png

理解abi.encodeWithSignature函数的使用方式后,便来使用一下delegatecall函数,我们构建出这样的结构:用户通过合约A调用合约B。

嗯,似乎跟多重合约类似?

将合约B的address传递给合约A,然后在合约A中实例化合约B再调用合约B的方法就好了?

是的,但这里,我们通过delegatecall函数的方式来做,顺便我们对比一下,delegatecall函数的做法与多重合约做法之间的差异。

先实现合约B:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

contract ContractB {
    uint256 public x;
    address public addr;

    event TransferLog(address sender_addr, uint256 amount, uint256 gas);

    function set_value(uint256 _x) public payable {
        x = _x;
        addr = msg.sender;

        if (msg.value > 0) {
          emit TransferLog(msg.sender, msg.value, gasleft());
        }
    }

    function getBalance() public view returns(uint256){
        return address(this).balance;
    }
}

在ContractB合约中,有x变量与addr变量,定义了set_value函数,用payable关键字标注,即该函数可以支持转账功能,最后还有getBalance函数用于查看当前合约的ETH余额。

ContractB合约平平无奇,没啥特别的,接着写ContractA合约:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

contract ContractA {
    uint256 public x;
    address public addr;

    event Response(bool success, bytes data);

    function delegatecall_set_value(address _addr, uint256 _x) external payable {
        // 通过delegatecall函数,代理调用ContractB中的set_value方法
        (bool status, bytes memory data) = _addr.delegatecall(
            // abi.encodeWithSignature函数编码set_value(uint256)获得二进制编码,给到delegatecall函数去调用
            abi.encodeWithSignature("set_value(uint256)", _x)
        );

        emit Response(status, data);
    }

    function getBalance() public view returns(uint256){
        return address(this).balance;
    }

}

ContractA合约中同样定义了x变量与addr变量,注意,solidity有插槽位置的概念,所以ContractA中的x变量与addr变量其顺序要跟ContractB的x变量与addr变量的位置相同,避免bug。

分别部署Contract A与Contract B,先看到Contract B,效果如下:

821ee437836301faf19baeb6d1ac4993.png

ETH余额为0,通过set_value函数设置了x变量后,x变量的值为1,而addr变量存的msg.sender为当前用户0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2。

接着,我们使用ContractA,使用delegatecall_set_value函数时,将合约B的address传入,然后设置_x变量的值为66。

效果如下:

4a9ef37b3405a96cee5d0c6bae33b38d.png

这里要注意的是,我们设置的数据,存放在了ContractA中,没错,这便是重点,通过delegatecall函数调用其他合约执行逻辑后,数据会留到当前合约中,基于这个特性,我们可以做到合约逻辑与合约数据分开存储的效果(delegatecall函数除了用在代理合约上,另外一个应用场景是EIP-2535(钻石合约))。

结合【关于可变合约的二三事(上)】多重合约的知识,可以知道,多重合约存储数据的位置在被调用合约中,而delegatecall函数实现的效果是数据存在调用合约中,而这也是代理合约与多重合约的区别。

代理合约

代理合约是基于delegatecall函数实现的,其实,上面介绍delegatecall函数的使用时,我们就实现了最朴素的代理合约,但这种合约其实是不可升级的,为啥?

我们看回ContractA合约,我们为了可以调用ContractB合约中的set_value函数,定义了delegatecall_set_value函数,如果ContractB合约中还有其他函数,我们依旧可以在ContractA中定义对应的函数,通过这种方式,虽然我们实现了代理合约(即数据存在ContractA中,逻辑放到ContractB中),但意义有限。

如果,后面ContractB更新成ContractC了,多了一些函数,ContractA合约无法调用ContractC合约中的新函数,因为我们没有在ContractA中定义好对应的方法,所以我们需要找到某个通用的形式让ContractA可以代理ContractC中的所有函数,就算增加了新的函数也无妨。

在讨论所谓通用形式前,先聊一下代理合约的基本概念。

主流项目使用代理合约的模式通常是将合约数据与合约逻辑分开处理,分别保存在不同的合约中,我画个图:

707eb54e8d395615d1fd87e29cb2ad5e.png

从上图可知,调用者会调用代理合约,代理合约会通过delegatecall函数去调用具体的逻辑合约,数据会存放在代理合约中,当逻辑需要改变时,直接部署新的逻辑合约,将代理合约关联到新的合约则可,此时数据不会丢失。

有些项目方会使用一套公共的逻辑合约关联多个不同的代理合约,分别用于宣发不同的项目。这些项目玩法都一样,只是NFT的图像可能会有所差别,这种操作对于项目方的好处是,省gas费,简单包装一下,还可以实现一个NFT项目工厂(我最近就在开发NFT项目工厂工具)。

接着,我们基于OpenZepplin实现的Proxy合约(https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/Proxy.sol)来实现代理合约,为了方便你理解,我将Proxy合约的代码帖上来(其中对fallback函数与receive函数的使用,便是上面提到的通用方法),并添加中文注释:

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.6.0) (proxy/Proxy.sol)

pragma solidity ^0.8.0;

abstract contract Proxy {

    function _delegate(address implementation) internal virtual {
        assembly {
            // 将msg.data拷贝到内存中
            calldatacopy(0, 0, calldatasize())
          	// 通过delegatecall调用implementation合约
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
          	// 将return data拷贝到内存中
            returndatacopy(0, 0, returndatasize())

            switch result
            // 如果delegatecall调用失败,revert
            case 0 {
                revert(0, returndatasize())
            }
            // 如果delegatecall调用成功,将内存中0到returndatasize()的数据返回(bytes格式)
            default {
                return(0, returndatasize())
            }
        }
    }

    function _implementation() internal view virtual returns (address);

    function _fallback() internal virtual {
        _beforeFallback();
        _delegate(_implementation());
    }

    fallback() external payable virtual {
        _fallback();
    }

    receive() external payable virtual {
        _fallback();
    }

    function _beforeFallback() internal virtual {}
}

Proxy合约有比较多可学习的地方,我们一个个来讨论。

首先是fallback函数与receive函数,这是solidity中两个特殊的回调函数,他们在2种情况下会被自动调用:

  • 1.接收了ETH时

  • 2.调用当前合约中不存在的函数时

在solidity 0.6.x 之前,solidity只有fallback()一个回调函数,负责所有工作,但solidity 0.6.x 之后,被拆分成了receive()与fallback()两个函数。

其中receive()只在接收ETH时被调用,一个合约中最多只能有一个receive函数,且它不需要通过function关键字来声明,基础语法如下:

receive() external payable {
  // do something...
}

此外,receive函数中不能写太过复杂的逻辑,因为receive函数有gas费的限制,有些恶意合约会利用这点来做恶(后面再写一篇相关的文章)。

当我们调用合约中不存在的函数时,fallback函数会被触发,利用这个特性,我们可以在fallback函数中使用delegatecall函数,实现代理合约,非常方便。

类似的,fallback函数也不需要function关键字,但必须使用external修饰,一般也会使用payable修饰,基础语法如下:

fallback() external payable {
  // do something...
}

看回openzeppelin的Proxy合约中的receive和fallback函数,他们都调用了_fallback函数,而_fallback函数会调用_delegate函数,而_delegate函数便是实现代理合约的关键函数。

_delegate函数使用了solidity内联汇编(inline assembly)的形式来实现委托调用的逻辑,为了便于阅读,复制出来:

function _delegate(address implementation) internal virtual {
  assembly {
      // 将msg.data拷贝到内存中
      calldatacopy(0, 0, calldatasize())
      // 通过delegatecall调用implementation合约
      let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
      // 将return data拷贝到内存中
      returndatacopy(0, 0, returndatasize())

      switch result
      // 如果delegatecall调用失败,revert
      case 0 {
          revert(0, returndatasize())
      }
      // 如果delegatecall调用成功,将内存中0到returndatasize()的数据返回(bytes格式)
      default {
          return(0, returndatasize())
      }
  }
}

具体的逻辑通过assembly关键字包裹,即使用inline assembly形式,这种形式可以让开发者更细粒度的控制合约,但使用难度也比较大,如果不太熟悉,solidity文档中是不推荐大家使用的(https://docs.soliditylang.org/en/v0.8.16/assembly.html)。

这里,为了方便你理解,解释一下_delegate函数中使用到的内联汇编操作码的具体作用:

  • calldatacopy(t, f, s):将calldata(输入数据)从位置f开始复制s字节到mem(内存)的位置t。

  • delegatecall(g, a, in, insize, out, outsize):调用地址a的合约,输入为mem[in..(in+insize)) ,输出为mem[out..(out+outsize)), 提供g的gas 和v wei的以太坊。这个操作码在错误时返回0,在成功时返回1。

  • returndatacopy(t, f, s):将returndata(输出数据)从位置f开始复制s字节到mem(内存)的位置t。

  • switch:基础版if/else,不同的情况case返回不同值。可以有一个默认的default情况。

  • return(p, s):终止函数执行, 返回数据mem[p..(p+s))。

  • revert(p, s):终止函数执行, 回滚状态,返回数据mem[p..(p+s))。

更多操作码可以查阅Yui(Yui是Solidity内联汇编的语言)的相关文档:https://solidity-cn.readthedocs.io/zh/master/assembly.html

当我第一次了解到_delegate函数这种写法时,我有个巨大的疑惑,为何不直接使用delegatecall函数来实现委托调用,要特意使用inline assembly形式,再利用inline assembly里的delegatecall操作码来实现委托调用?

其核心原因是:fallback()函数的语法限制。

查阅solidity文档可知,fallback()函数有两种使用方式:

  • fallback () external [payable]

  • fallback (bytes calldata input) external [payable] returns (bytes memory output)

第一种形式,fallback()函数没有returns,即无法返回值,第二种形式要求传bytes类型的参数,两种类型都有限制。

因为这种情况,openzeppelin才使用inline assembly的形式,跳脱出solidity的限制,直接操作内存将数据返回,最终实现通过第一种方式使用fallback函数但却可以获得返回数据的效果。

我们简化一下openzeppelin的Proxy合约,来写一个简单的代理合约(不可直接用于真实项目),代码如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

contract Proxy {
    address public implementation; 
    address public admin;
    uint256 public x = 0;

    constructor() {
        admin = msg.sender;
    }

    modifier onlyAdmin {
        require(msg.sender == admin);
        _;
    }


    function setImplementation(address _implementation) external onlyAdmin{
        implementation = _implementation;
    }


    fallback() external payable {
        _delegate();
    }

    receive() external payable {
        _delegate();
    }
    
    
    function _delegate() internal {
        assembly {
    
            let _implementation := sload(0)

            calldatacopy(0, 0, calldatasize())


            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            returndatacopy(0, 0, returndatasize())

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

}


contract LogicsContract {
    address public implementation;
    address public admin;

    uint256 public x = 0;

    function add_x(uint256 _x) external returns(uint256) {
        x += _x;
        return x;
    }

}


contract Caller {
    address public proxy;

    constructor(address _proxy) {
        proxy = _proxy;
    }

    function add_x_proxy(uint256 _x) external returns(uint256) {
         // 调用代理合约中的add_x函数,因为该函数不存在,会调用代理合约的fallback函数
         (, bytes memory data) = proxy.call(abi.encodeWithSignature("add_x(uint256)", _x));
         return abi.decode(data, (uint256));
    }
}

上述代码中,我们定义了Proxy合约,实现了代理逻辑,用于存储业务数据;我们定义了LogicsContract合约,实现的业务逻辑(即add_x函数);最后,我们定义了Caller合约,该合约会调用Proxy合约。

简单的流程为:Caller合约调用Proxy合约中的add_x()函数,因为add_x()函数不存在,则会回调fallback函数,fallback函数通过_delegate函数实现委托代理的逻辑,最终调用LogicsContract合约中的add_x()方法,因为_delegate函数基于inline assembly实现相关操作的,所以Caller合约可以获得相应的返回值。

我们部署一下,操作结果如下:

613589ef76f82ea8720da5298b4d644b.png

结尾

因为内容长度原因,本文就先到这里,但需要注意的是,文章只讨论了代理合约最基本的形式,在真实的项目中,还需要考虑很多内容。

最近有个名叫TwitterScan的数据分析工具很火,该工具通过NFT的形式来运营,其相关合约便是代理合约(https://etherscan.io/token/0xd9372167ef419cfbbcd6483603ad15976364e557#code),如果你去看其代码,会发现,光有本文的知识,还是不太懂。

你还需要了解:

  • solidity slot clash(插槽冲突)

  • 透明合约、UUPS解决方案

  • 在代理合约上更进一步的钻石合约

嗯,上面的技术,在不同的真实项目中,都会有所涉及,在后面的文章中,我会慢慢分享给大家。

我是二两,下篇文章见。

猜你喜欢

转载自blog.csdn.net/weixin_30230009/article/details/127312438
今日推荐