一种压缩Solidity函数参数的方法

我们知道,在Solidity的合约调用中,函数参数会自动被扩展为256位(32字节),这就意味着哪怕是一个bool类型的参数,它也会完全占用32字节(64位)。我们知道,布尔的true实际中会编码成01,那么它前面的62个0只是填充位,是没有实际用处的。但是我们仍然需要为这些填充的0付gas费用。于是有人就想,能否压缩一下参数,不去支付这个额外的费用呢?答案是可以尝试一下的。

在UniswapV3中,也意识到了这一点,于是他们也部分采用了压缩参数的方法。并且给出了在Solidity中进行解码地址类型和Uint24类型变量的方法。本文正是基于此解码进行的一个自我学习。

基本思路为:我们要调用的函数不定义参数,所有需要使用的参数按顺序压缩编码后放在payload中,发交易时直接构造交易对象然后签名发送。我们一步一步来重现这个过程。

一、新建一个工程

这里我们推荐使用hardhat来新建一个工程,这里我们使用yarn作为包管理器。

  1. 打开控制台,切换到工作目录,例如work目录。

  2. 运行mkdir encode_param && cd encode_param && yarn init并一路回车。

  3. 运行yarn add hardhat --dev,耐心等待完成。

  4. 运行npx hardhat,选择Create a basic sample project并一路回车。

  5. 运行code .使用vscode打开,或者手动使用vscode打开。

  6. Greeter.sol重命名为DecodeParams.sol,并替换合约内容如下:

    //SPDX-License-Identifier: Unlicense
    pragma solidity =0.6.6;
    contract DecodeParams {
          
          
        event ParamDecode(address user1,address user2,bool isVip, uint112 amount,uint24 fee0,uint24 fee1,uint24 fee2); 
    
        function callWithParams(
            address user1,
            address user2,
            bool isVip,
            uint112 amount,
            uint24 fee0,
            uint24 fee1,
            uint24 fee2
        ) external {
          
          
            emit ParamDecode(user1,user2,isVip,amount,fee0,fee1,fee2);
        }
    }
    

    合约的内容很简单,就是定义了多个参数的函数,然后在函数中触发一个相应的事件来记录全部参数。

  7. 修改hardhat.config.js,将solidity编译器版本改为0.6.6。

    module.exports = {
          
          
      solidity: "0.6.6",
    };
    
  8. 运行npx hardhat compile进行编译。

  9. 写一个简单的单元测试来进行测试,将test/sample-test.js的内容替换如下:

    const {
          
           expect } = require("chai");
    const {
          
           ethers } = require("hardhat");
    
    describe("DecodeParams", function () {
          
          
    
      let instance;
      let user1,user2,users;
      let args;
    
      beforeEach( async () => {
          
          
        const DecodeParams = await ethers.getContractFactory("DecodeParams");
        instance = await DecodeParams.deploy();
    
        [user1,user2,...users] = await ethers.getSigners();
        args = [user1.address,user2.address,true,ethers.utils.parseEther("1003.59"),30,15,8];
      });
    
      describe("Call with params and without params", () => {
          
          
        it("Should emit ParamDecode with function params", async function () {
          
          
          await expect(instance.callWithParams(...args)).to.emit(instance,"ParamDecode")
            .withArgs(...args);
        });
      })
      
    });
    
  10. 运行单元测试npx hardhat test,会得到 1 passing的结果。

二、获取消耗的gas

我们进行测试的最终目的是比较参数压缩和参数不压缩时的gas消耗。因此我们非常有必要先获取正常调用时的gas消耗。我们使用hardhat-gas-reporter插件来获取。

  1. 项目根目录下运行yarn add hardhat-gas-reporter --dev来安装该插件。

  2. 编辑配置文件hardhat.config.js,在第二行添加如下内容:require("hardhat-gas-reporter");

  3. 再次运行npx hardhat test来进行单元测试,此时会显示消耗的gas。

    ·-----------------------------------|----------------------------|-------------|-----------------------------·
    |        Solc version: 0.6.6        ·  Optimizer enabled: false  ·  Runs: 200  ·  Block limit: 30000000 gas  │
    ····································|····························|·············|······························
    |  Methods                                                                                                   │
    ·················|··················|··············|·············|·············|···············|··············
    |  Contract      ·  Method          ·  Min         ·  Max        ·  Avg        ·  # calls      ·  eur (avg)  │
    ·················|··················|··············|·············|·············|···············|··············
    |  DecodeParams  ·  callWithParams  ·           -  ·          -  ·      25819  ·            2  ·          -  │
    ·················|··················|··············|·············|·············|···············|··············
    |  Deployments                      ·                                          ·  % of limit   ·             │
    ····································|··············|·············|·············|···············|··············
    |  DecodeParams                     ·           -  ·          -  ·     172459  ·        0.6 %  ·          -  │
    ·-----------------------------------|--------------|-------------|-------------|---------------|-------------·
    

    可以看到,运行我们的callWithParams函数消耗了 25819 的gas。

    ps:未知原因,需要使用VPN才能获取到结果,否则运行npx hardhat test会长时间卡住。

三、参数编码后直接发送交易。

在我们上面的测试中,我们直接调用了合约的函数来进行测试。而我们知道,其底层其实相当于一个api调用,是将一个交易对象签名后发送给了节点服务器。这种方式和我们使用参数压缩的方式更为接近,因此,我们将函数参数编码后再发送一次交易进行尝试。

我们修改一下sample-test.js,增加以下代码片断以进行一个新的测试:

 describe("Call with params and without params", () => {
    
    
    // ....
    it("Call with encode function params", async () => {
    
    
      let data = instance.interface.encodeFunctionData("callWithParams",args)
      let transaction = {
    
    
        to:instance.address,
        data,
      }
      await expect(user1.sendTransaction(transaction)).to.emit(instance,"ParamDecode").withArgs(...args);
      console.log("data:",data);
    });
  })

可以看到,我们上面的测试是先编码参数,然后构建一个交易对象,最后使用钱包签名发送该交易对象。

我们再次运行npx hardhat test,仍然会得到相同的结果(除了#calls的值有所不同),从输出结果中可以得到,我们使用的gas仍然为25819

最后,我们打印出了那个编码后的payload,为:

0xe9aecc51000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c80000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000036679bebc096470000000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000000f0000000000000000000000000000000000000000000000000000000000000008

我们下一步的目标就是压缩这个payload,离目标很近了。

四、分析payload

下一步压缩之前,我们先分析一下这个payload。注意,这里显示的是人类易读的16进制,因此会有"0x"前缀,在真实调用时,是没有这两个字符的,切记。

有人说为什么这个payload怎么这么长啊,是因为32字节对应64位长度啊,一个字节8位,一个16进制数据4位,一个字节等于2个16进制数据。

最开始4个字节(8位)是函数选择器,就是用来匹配合约中哪个函数的,其有专门的计算方法,大抵是将函数和参数列表合起来计算一个哈希再取前四位。笔者以前有篇文章介绍了其计算方法,这里就不再重复了。

接下来的000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266是我们第一个参数,user1,可以看到,我们的地址只用了40位(Uint160),前面有24个0是没有用的,我们的目标就是把它压缩掉。

同理,00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8是我们第二个参数,user2,同样也要压缩掉前面24个0。

0000000000000000000000000000000000000000000000000000000000000001就是我们的布尔值true了,因为Solidity中最小整数类型为uint8,我们使用它来替代bool类型,它为2个16进制数据,因此前面的 62(64-2)个0都是无用的,需要压缩掉。

000000000000000000000000000000000000000000000036679bebc096470000是我们的参数ethers.utils.parseEther(“1003.59”)。因为我们的数据类型是uint112,它为112/8=14个字节占28位长度16进制数据,所以前面 36个0是无用的。

000000000000000000000000000000000000000000000000000000000000001e是我们的参数fee0。因为它是uint24类型的,因此只需要24/8*2= 6位长度16进制数据就能显示,所以前面 58个0都是无用的。

后面的fee1fee2也是同样的。

通过上面的分析我们可以知道,总长度为 7 * 64 = 448 的16进制数据中(不算函数选择器),有 24 * 2 + 62 + 36 + 58 * 3 = 320 个0是无用的,占比约 71.4%。因此我们浪费了相当多的空间(特别是涉及到一些长度很小的数据类型),我们只需要 448 - 320 = 128 个 16进制数据。

我们压缩参数就是为了去掉这些多余的0,把它们 紧密排列在一起,然后在合约中解析出来。

五、压缩参数编码

还记得我们上面编码后手动发送交易的方法么?我们简单的把payload压缩一下再发送出去就行了。

我们首先修改合约,编辑DecodeParams.sol,增加一个空函数:

function callWithoutParams() external {
  //todo
}

修改sample-test.js,增加压缩参数的相关内容,暂时的代码如下:

describe("Call with params and without params", () => {
    
    

    /**
     * 
     * @param {*} v 10进制数|16进制或者BigNumber
     * @param {*} l 数据长度,如uint112就是112
     * @return 返回无多余填充0的编码
     */
    function encodeUint(v,l) {
    
    
      let b = ethers.BigNumber.from(v)
      b = b.toHexString().substring(2);
      let len = l/8 * 2;
      assert.ok(b.length <= len,"out of bounds");
      return "0".repeat(len - b.length) + b;
    }

    // it("Should emit ParamDecode with function params", async function () {
    
    
    //   await expect(instance.callWithParams(...args)).to.emit(instance,"ParamDecode").withArgs(...args);
    // });

    // it("Call with encode function params", async () => {
    
    
    //   let data = instance.interface.encodeFunctionData("callWithParams",args)
    //   let transaction = {
    
    
    //     to:instance.address,
    //     data,
    //   }
    //   await expect(user1.sendTransaction(transaction)).to.emit(instance,"ParamDecode").withArgs(...args);
    //   console.log("data:",data);
    // });

    it("Call with packed function params", async () => {
    
    
      let selector = instance.interface.encodeFunctionData("callWithoutParams",[]); //selector,因为此时没有参数
      let data = selector + args[0].substring(2) + args[1].substring(2) + encodeUint(args[2] ? 1 : 0,8);
      data += encodeUint(args[3],112) + encodeUint(args[4],24) + encodeUint(args[5],24) + encodeUint(args[6],24);
      console.log("data:",data);
      //check params
      assert.equal(data.substring(0,10),selector);
      assert.equal("0x" + data.substring(10,50),user1.address);
      assert.equal("0x" + data.substring(50,90),user2.address);
      assert.equal(data.substring(90,92),args[2] ? "01" : "00");
      let amount = ethers.BigNumber.from("0x" + data.substring(92,92 + 28));
      assert.ok(amount.eq(args[3]));
      let fee0 = ethers.BigNumber.from("0x" + data.substring(120,126))
      assert.equal(+ fee0.toString(), args[4])
      let fee1 = ethers.BigNumber.from("0x" + data.substring(126,132))
      assert.equal(+ fee1.toString(), args[5])
      let fee2 = ethers.BigNumber.from("0x" + data.substring(132,138))
      assert.equal(+ fee2.toString(), args[6])
      assert.ok(data.length === 128 + 10);
      let transaction = {
    
    
        to:instance.address,
        data,
      }
    });

  })

为了节省时间,我们注释掉了前两个测试,稍后会重新打开它,同时我们在hardhat.config.js中注释掉require("hardhat-gas-reporter");,我们也会在稍后重新打开它。

运行npx hardhat test,会输出我们压缩后的编码:

0xad094ee6f39Fd6e51aad88F6F4ce6aB8827279cffFb9226670997970C51812dc3A010C7d01b50e0d17dc79C801000000000036679bebc09647000000001e00000f000008

同样,这里ad094ee6为函数选择器,在上面的单元测试中,已经详细验证了编码的数据刚好同我们args中的对应的元素相同。

在单元测试的最后,我们构造了一个交易对象准备接下来使用,可以看到这里的payload就是我们的data。

六、在合约中解码

在合约中解码就是将payload(msg.data)解码成我们需要的参数。注意,合约中的msg.data就是上述最后一个单元测试中的data,让我们把合约中那个空函数增加实现。

function callWithoutParams() external {
    
    
    address user1;
    address user2;
    uint8 v_amount; //经过多次测试,直接从汇编中读取bool变量为整个32字节,因此这里需要读取uint8的值。
    uint112 amount;
    uint24 fee0;
    uint24 fee1;
    uint24 fee2;
    bytes memory data = msg.data; //这里msg.data位于calldata,所以必须复制一份
    assembly {
    
    
        user1 := mload(add(data, 24))
        user2 := mload(add(data, 44))
        v_amount := mload(add(data, 45))
        amount := mload(add(data,59))
        fee0 := mload(add(data,62))
        fee1 := mload(add(data,65))
        fee2 := mload(add(data,68))
    }
    emit ParamDecode(user1,user2,v_amount > 0,amount,fee0,fee1,fee2);
}

运行npx hardhat compile来编译合约。

七、完成对比测试

取消在第五步中注释的测试和hardhat.config.js中的require("hardhat-gas-reporter");注释。在sample-test.js"Call with packed function params" 测试中最下方添加下面这一行

await expect(user1.sendTransaction(transaction)).to.emit(instance,"ParamDecode").withArgs(...args);

完整的单元测试文件如下:

const {
    
     expect, assert } = require("chai");
const {
    
     ethers } = require("hardhat");

describe("DecodeParams", function () {
    
    

  let instance;
  let user1,user2,users;
  let args;

  beforeEach( async () => {
    
    
    const DecodeParams = await ethers.getContractFactory("DecodeParams");
    instance = await DecodeParams.deploy();
    [user1,user2,...users] = await ethers.getSigners();
    args = [user1.address,user2.address,true,ethers.utils.parseEther("1003.59"),30,15,8];
  });

  describe("Call with params and without params", () => {
    
    

    /**
     * 
     * @param {*} v 10进制数|16进制或者BigNumber
     * @param {*} l 数据长度,如uint112就是112
     * @return 返回无多余填充0的编码
     */
    function encodeUint(v,l) {
    
    
      let b = ethers.BigNumber.from(v)
      b = b.toHexString().substring(2);
      let len = l/8 * 2;
      assert.ok(b.length <= len,"out of bounds");
      return "0".repeat(len - b.length) + b;
    }
    
    it("Should emit ParamDecode with function params", async function () {
    
    
      await expect(instance.callWithParams(...args)).to.emit(instance,"ParamDecode").withArgs(...args);
    });

    it("Call with encode function params", async () => {
    
    
      let data = instance.interface.encodeFunctionData("callWithParams",args)
      let transaction = {
    
    
        to:instance.address,
        data,
      }
      await expect(user1.sendTransaction(transaction)).to.emit(instance,"ParamDecode").withArgs(...args);
    });

    it("Call with packed function params", async () => {
    
    
      let selector = instance.interface.encodeFunctionData("callWithoutParams",[]); //selector,因为此时没有参数
      let data = selector + args[0].substring(2) + args[1].substring(2) + encodeUint(args[2] ? 1 : 0,8);
      data += encodeUint(args[3],112) + encodeUint(args[4],24) + encodeUint(args[5],24) + encodeUint(args[6],24);
      //check params
      assert.equal(data.substring(0,10),selector);
      assert.equal("0x" + data.substring(10,50),user1.address);
      assert.equal("0x" + data.substring(50,90),user2.address);
      assert.equal(data.substring(90,92),args[2] ? "01" : "00");
      let amount = ethers.BigNumber.from("0x" + data.substring(92,92 + 28));
      assert.ok(amount.eq(args[3]));
      let fee0 = ethers.BigNumber.from("0x" + data.substring(120,126))
      assert.equal(+ fee0.toString(), args[4])
      let fee1 = ethers.BigNumber.from("0x" + data.substring(126,132))
      assert.equal(+ fee1.toString(), args[5])
      let fee2 = ethers.BigNumber.from("0x" + data.substring(132,138))
      assert.equal(+ fee2.toString(), args[6])
      assert.ok(data.length === 128 + 10);
      let transaction = {
    
    
        to:instance.address,
        data,
      }
      await expect(user1.sendTransaction(transaction)).to.emit(instance,"ParamDecode").withArgs(...args);
    });
  })
  
});

我们再次运行npx hardhat test,得到如下结果:

➜  encode_param npx hardhat test  

  DecodeParams
    Call with params and without params
      ✓ Should emit ParamDecode with function params
      ✓ Call with encode function params
      ✓ Call with packed function params

·--------------------------------------|----------------------------|-------------|-----------------------------·
|         Solc version: 0.6.6          ·  Optimizer enabled: false  ·  Runs: 200  ·  Block limit: 30000000 gas  │
·······································|····························|·············|······························
|  Methods                                                                                                      │
·················|·····················|··············|·············|·············|···············|··············
|  Contract      ·  Method             ·  Min         ·  Max        ·  Avg        ·  # calls      ·  eur (avg)  │
·················|·····················|··············|·············|·············|···············|··············
|  DecodeParams  ·  callWithoutParams  ·           -  ·          -  ·      25251  ·            2  ·          -  │
·················|·····················|··············|·············|·············|···············|··············
|  DecodeParams  ·  callWithParams     ·           -  ·          -  ·      25841  ·            4  ·          -  │
·················|·····················|··············|·············|·············|···············|··············
|  Deployments                         ·                                          ·  % of limit   ·             │
·······································|··············|·············|·············|···············|··············
|  DecodeParams                        ·           -  ·          -  ·     264854  ·        0.9 %  ·          -  │
·--------------------------------------|--------------|-------------|-------------|---------------|-------------·

  3 passing (893ms)

从上面的结果中可以看出,我们使用压缩参数后的函数调用callWithoutParams的gas消耗为 25251, 而未使用参数压缩的函数调用的gas消耗为25841,节省了590的gas,算是比较有限的。也许是方法不对。

八、合约解析讲解

完整的示例合约代码为:

//SPDX-License-Identifier: Unlicense
pragma solidity =0.6.6;
contract DecodeParams {
    
    
    event ParamDecode(address user1,address user2, bool isVip, uint112 amount,uint24 fee0,uint24 fee1,uint24 fee2); 
    function callWithParams(
        address user1,
        address user2,
        bool isVip,
        uint112 amount,
        uint24 fee0,
        uint24 fee1,
        uint24 fee2
    ) external {
    
    
        emit ParamDecode(user1,user2,isVip,amount,fee0,fee1,fee2);
    }

    function callWithoutParams() external {
    
    
        address user1;
        address user2;
        uint8 v_amount; //经过多次测试,直接从汇编中读取bool变量为整个32字节,因此这里需要读取uint8的值。
        uint112 amount;
        uint24 fee0;
        uint24 fee1;
        uint24 fee2;
        bytes memory data = msg.data; //这里msg.data位于calldata,所以必须复制一份
        assembly {
    
    
            user1 := mload(add(data, 24))
            user2 := mload(add(data, 44))
            v_amount := mload(add(data, 45))
            amount := mload(add(data,59))
            fee0 := mload(add(data,62))
            fee1 := mload(add(data,65))
            fee2 := mload(add(data,68))
        }
        emit ParamDecode(user1,user2,v_amount > 0,amount,fee0,fee1,fee2);
    }

}

这里简单讲解一下解析地址和整数的方法。

这里需要补充一些预备知识:

  • 这里的data其实是内存地址,相当于一个指针。
  • 因为我们这个msg.data中包含有函数选择器,所以第一个参数的起始位置 为 data + 0x20(长度前缀) + 4(函数选择器的长度)。
  • 每次mload得到一个word(32字节)的内容,转换成相应的整数类型(包括地址类型)从直接从右边截断,因此我们想办法把解析的参数值放在某个word的最右边就行了。

综上所述,我们可以得到一个公式:

value := mload(add(data,start + len))

这个公式,左边就是我们要读取的参数值,右边的start是该参数在data中的起始位置(注意是字节数),len为该类型数据的长度(也是字节数),这个长度和我们在压缩参数时的长度是一致的。

我们把内存指针向后移动start + len位置 ,就刚好把相应的参数放在从指针开始的32字节的最后了。因此,user1的开始位置为4,长度为20,所以我们得到:user1 := mload(add(data, 24))。 那么user2比user1开始位置多20字节(多了一个user1),所以我们得到:user2 := mload(add(data, 44))。同样,后面的解析也就相当简单了,只需要记住uint112为14个字节,uint8为一个字节,uint24为3个字节就行了。

九、结论

经过本次测试,发现函数参数压缩能节省gas,但很有限。节省有限的原因是使用压缩方式时需要将msg.data复制到内存中,这会是一笔不小的开销。也许是笔者的方法不对,有待其它读者指出更好的压缩参数节省gas的方法。

猜你喜欢

转载自blog.csdn.net/weixin_39430411/article/details/123154579