Solidity代理/实现模式中实现合约回调函数的使用

Posted MateZero

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Solidity代理/实现模式中实现合约回调函数的使用相关的知识,希望对你有一定的参考价值。

Solidity代理/实现模式中实现合约回调函数的使用

我们知道,在Solidity中,合约有一个fallback函数,用于在函数调用时未匹配到相应的函数时调用。我们平常使用的代理/实现模式,正是基于这样一个功能。我们调用代理合约中的一个函数,该函数在代理合约中并不存在,于是通过fallback函数来调用委托调用实现合约的相应函数(代码片断)。不过,当实现合约中也未匹配到相应的函数调用怎么办?这时,就是举调用实现合约的fallback函数(假如定义了的话)。而在此fallback函数里,我们可以正常的进行合约编码,从而得到功能上的拓展。

一、应用场景

本文基于Solidity 0.6.0以上,因为0.6.0以下并不叫fallback函数。

假定有这么一个场景,代理合约A,实现合约B。 有这么一组合约:C,D,E,F,G…。等。他们都会调用代理合约一个函数,这些函数名称不同但是处理过程却相同。因为合约A为代理,其本身是没有实现代码的,所有实现代码都在B里。如果我们把C,D,E对应的函数调用都在B里的实现的话,第一麻烦,第二如果有新的CC,DD合约出现怎么办?此时,我们可以灵活应用合约B的fallback函数,统一处理这些调用。

合约函数调用时,一般重要的参数有调用者(msg.sender)和调用参数(payload)。而fallback函数是没有参数,所以无法直接进行参数传递。那么我们怎么获取传递的函数参数呢?我们知道,合约间的函数调用(或者外部账号调用合约),都是将调用参数编码为payload进行传递。调用正常的函数时,EVM自动帮我们解析了。而调用fallback这类没有参数的函数时,因为函数没有定义参数类型,所以需要手动解析。这里我们利用UniswapV3中的方法进行解析。

这里讲一点题外话,因为我们正常进行函数编码时,每个函数参数都被拓展为一个word,也就是256位,64字节。而我们平常使用的绝大部分数据是用不到64字节的,例如地址只使用了40字节,前面的24字节会拓展为0,而这些拓展的0也是会并入函数调用时的gas计算的,但是这些0对我们的数据其实是没有用处的。当有大量的拓展0出现时,会大大的影响gas利用率。因此,UniswapV3采用了一个压缩编码的方式来节省gas。既然压缩编码了,当然它也提供了相应的解码函数,我们直接拿过来使用即可。

二、示例合约编写

下面是我们编写的一个示例合约:

FallbackTest.sol

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";

interface IProxy 
    function setGreeting(uint value) external;


contract Test 
    address public proxy;

    constructor(address _proxy) 
        proxy = _proxy;
    
    function test(uint value) public 
        console.log("in test function:");
        IProxy(proxy).setGreeting(value);
    


contract Proxy 
    address public impl; //这里应该指定插槽,这里简化了。

    constructor(address _impl) 
        impl = _impl;
    

    fallback () external payable virtual 
        console.log("in proxy fallback");
        _delegate(impl);
    


    function _delegate(address implementation) internal virtual 
        // solhint-disable-next-line no-inline-assembly
        assembly 
            // Copy msg.data. We take full control of memory in this inline assembly
            // block because it will not return to Solidity code. We overwrite the
            // Solidity scratch pad at memory position 0.
            calldatacopy(0, 0, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

            // Copy the returned data.
            returndatacopy(0, 0, returndatasize())

            switch result
            // delegatecall returns 0 on error.
            case 0  revert(0, returndatasize()) 
            default  return(0, returndatasize()) 
        
    


contract Impl 
    address public impl_address; //未使用,用来防止插槽共享冲突
    address public caller;

    //演示获取的msg.sender及相应数据
    fallback () external payable virtual 
        console.log("in impl fallback");
        caller = msg.sender;
        bytes memory data = msg.data;
        uint value = toUint256(data,4);
        console.log("value:", value);
        deal(value);
    

    function toUint256(bytes memory _bytes, uint256 _start) internal pure returns (uint24) 
        require(_start + 32 >= _start, 'toUint256_overflow');
        require(_bytes.length >= _start + 32, 'toUint256_outOfBounds');
        uint24 tempUint;
        assembly 
            tempUint := mload(add(add(_bytes, 0x20), _start))
        
        return tempUint;
    

    function deal(uint value) internal 
        console.log("in impl deal function:",value);
    


从代码中可以看到,我们使用Hardhat工具进行开发。使用一个Test合约调用代理合约,代理合约再委托调用实现合约,实现合约在fallback函数里解析相应数据,再传递给处理函数。我们使用console.log来输出日志查看整个调用流程。

这里需要注意的是我们在Impl合约里第一个状态变量指定了一个未使用的状态变量。这是因为代理合约的第一个插槽对应的状态变量为address public impl,实现合约的第一个状态变量(也是在第一个插槽,也就是slot0)会和代理合约共享(这里可以看出合约内部其实是按插槽位置访问变量的,定义的变量位置部分的确定了插槽位置)。于是我们先定义了impl_address来占用第一个插槽(Slot0),我们正式使用的变量从第2个变量caller开始(插槽1)。

我们的toUint256函数从UniswapV3的toUint24改动而来。原来是uint24,3个字节。我们改成uint256,32个字节。tempUint := mload(add(add(_bytes, 0x20), _start))这里是因为十进制32是16进制0x20

三、测试脚本

合约编写完毕,我们来进行测试。

编写相应的测试脚本 sample-test.js

const  expect  = require("chai");
const  ethers  = require("hardhat");

describe("FallbackTest", function () 
  it("Should be Success", async function () 
    const Impl = await ethers.getContractFactory("Impl");
    const Proxy = await ethers.getContractFactory("Proxy");
    const Test = await ethers.getContractFactory("Test");

    const impl = await Impl.deploy();
    await impl.deployed();
    console.log("impl:",impl.address)

    const proxy = await Proxy.deploy(impl.address)
    await proxy.deployed();
    console.log("proxy:",proxy.address)

    const test = await Test.deploy(proxy.address)
    await test.deployed()
    console.log("test:",test.address)

    let instance =  await Impl.attach(proxy.address)

    expect(instance.address).to.equal(proxy.address)
    expect(await instance.caller()).to.equal(ethers.constants.AddressZero);

    const testTx = await test.test(12);
    await testTx.wait();

    expect(await instance.caller()).to.equal(test.address);
  );
);

接下来我们运行脚本:npx hardhat test

输出类似如下:

  FallbackTest
impl: 0x5FbDB2315678afecb367f032d93F642f64180aa3
proxy: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
test: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
in proxy fallback
in test function:
in proxy fallback
in impl fallback
value: 12
in impl deal function: 12
in proxy fallback

上面的输出结果验证了我们合约的执行流程,并正确解析出了相应的参数值12

四、拓展

单看上面的示例,应用的场景可能不多(其中一个场景为多个类UniswapV2交易所的交易对闪电贷回调接口)。但是我们在实现合约的fallback函数里可以再次委托调用另一个实现合约,神奇不?也就是一个代理合约可以有多个实现合约。这种情况一般用于合约过大,一个代理合约处理不了的情况。但这种情况下更多的是考虑重新设计,拆分功能到子合约,而不是首先考虑使用多个实现合约。

以上是关于Solidity代理/实现模式中实现合约回调函数的使用的主要内容,如果未能解决你的问题,请参考以下文章

solidity代理合约

第127篇 solidity 中链表的实现

在 Python 中实现回调 - 将可调用引用传递给当前函数

在测试中处理 Solidity 合约抛出的模式是啥

Solidity 学习

Objective-C中的委托(代理)模式