源起
前几天群里有讨论 Uniswap V3 中询价的处理,简单翻了下代码,发现与 Uniswap V2 相比,V3 变化真的很大~
其中 v3-periphery 目录下的 Path.sol
用于编码交易对路径,主要是为了节省 gas
我正好在写套利机器人,优化多多益善;于是尝试复用,并做了进一步的优化
这里简单记录下
原理
交易的 intrinsic 成本计算如下
// go-ethereum/core/state_transition.go
// IntrinsicGas computes the 'intrinsic gas' for a message with the given data.
func IntrinsicGas(data []byte, accessList types.AccessList, isContractCreation bool, isHomestead, isEIP2028 bool) (uint64, error) {
// Set the starting gas for the raw transaction
var gas uint64
// Bump the required gas by the amount of transactional data
if len(data) > 0 {
// Zero and non-zero bytes are priced differently
var nz uint64
for _, byt := range data {
if byt != 0 {
nz++
}
}
// Make sure we don't exceed uint64 for all data combinations
nonZeroGas = params.TxDataNonZeroGasEIP2028
if (math.MaxUint64-gas)/nonZeroGas < nz {
return 0, ErrGasUintOverflow
}
gas += nz * nonZeroGas
z := uint64(len(data)) - nz
if (math.MaxUint64-gas)/params.TxDataZeroGas < z {
return 0, ErrGasUintOverflow
}
gas += z * params.TxDataZeroGas
}
return gas, nil
}
其中,TxDataZeroGas
为 4,TxDataNonZeroGasEIP2028
为 16,即 input data 的空字节和非空字节,gas 分别为 4 wei 和 16 wei
为了节省 gas,我们需要尽量减少 input data
编码
很不幸的是,在 solidity 中,数据编码几乎未考虑 gas 优化,一切以简单为前提
比如,我们需要传递 pair 与 fee 的 tuple
到合约,假设数据为:
pool | fee |
---|---|
0x55542f696a3fEcaE1C937Bd2e777B130587cFD2d | 500 |
0x9D7076AD0F7fDc5F0F249e97721D36a448d24906 | 3000 |
0x6CE15889C141C09Ecf76a57795E91214A1f97648 | 10000 |
0xdfc647c079757bac4f7776cc876746119Ac451ea | 10000 |
// 这里数据仅做例子,没有实际意义
对应函数的原型,非常影响 gas
原始编码
函数原型为
function flashArbs(address[] calldata pool, uint24[] calldata fee) external;
数据编码为
0000000000000000000000000000000000000000000000000000000000000040 // pool.offset
00000000000000000000000000000000000000000000000000000000000000e0 // fee.offset
0000000000000000000000000000000000000000000000000000000000000004 // pool.length
00000000000000000000000055542f696a3fecae1c937bd2e777b130587cfd2d // pool[0]
0000000000000000000000009d7076ad0f7fdc5f0f249e97721d36a448d24906 // pool[1]
0000000000000000000000006ce15889c141c09ecf76a57795e91214a1f97648 // pool[2]
000000000000000000000000dfc647c079757bac4f7776cc876746119ac451ea // pool[3]
0000000000000000000000000000000000000000000000000000000000000004 // fee.length
00000000000000000000000000000000000000000000000000000000000001f4 // fee[0]
0000000000000000000000000000000000000000000000000000000000000bb8 // fee[1]
0000000000000000000000000000000000000000000000000000000000002710 // fee[2]
0000000000000000000000000000000000000000000000000000000000002710 // fee[3]
编码过程参考 Formal Specification of the Encoding,这里不做赘述
消耗 gas 为 292 x 4 + 92 x 16 = 2640
简单优化
上面例子中,可以看到两个数组分别有自己的 offset 和 length,额外消耗了 gas
容易想到,我们可以将 pool 和 fee 组织为结构体,以结构体数组的形式传递参数
函数原型为
struct PoolTier {
address pool;
uint24 fee;
}
function flashArbs(PoolTier[] calldata input) external;
数据编码为
0000000000000000000000000000000000000000000000000000000000000020 // input.offset
0000000000000000000000000000000000000000000000000000000000000004 // input.length
00000000000000000000000055542f696a3fecae1c937bd2e777b130587cfd2d // input[0]
00000000000000000000000000000000000000000000000000000000000001f4
0000000000000000000000009d7076ad0f7fdc5f0f249e97721d36a448d24906 // input[1]
0000000000000000000000000000000000000000000000000000000000000bb8
0000000000000000000000006ce15889c141c09ecf76a57795e91214a1f97648 // input[2]
0000000000000000000000000000000000000000000000000000000000002710
000000000000000000000000dfc647c079757bac4f7776cc876746119ac451ea // input[3]
0000000000000000000000000000000000000000000000000000000000002710
消耗 gas 为 230 x 4 + 90 x 16 = 2360
节省 gas 为 280
Uniswap V3 优化
从上面两个例子可以看到,solidity 编码的最大问题在于 padding,即 32 字节对齐,导致引入了非常多无效的空字节
上述例子中 gas 为 2360,而空字节消耗了 230 * 4 = 920,无效数据占比为 ~ 40%
为了进一步优化,考虑到 pool 和 fee 都为定长类型,可以直接拼接而不做 padding,在实际使用时才做解码
函数原型为
function flashArbs(bytes calldata input) external;
数据编码为
0000000000000000000000000000000000000000000000000000000000000020
000000000000000000000000000000000000000000000000000000000000005c
55542f696a3fecae1c937bd2e777b130587cfd2d0001f4
9d7076ad0f7fdc5f0f249e97721d36a448d24906000bb8
6ce15889c141c09ecf76a57795e91214a1f97648002710
dfc647c079757bac4f7776cc876746119ac451ea002710
00000000 // padding
消耗 gas 为 66 x 4 + 90 x 16 = 1704,无效数据占比降至 ~ 15%
这也是 Uniswap V3 的优化方式
优化
实际上,我们继续优化,使得有效载荷为 100%
函数原型为
function flashArbs() external;
数据编码为
55542f696a3fecae1c937bd2e777b130587cfd2d0001f4
9d7076ad0f7fdc5f0f249e97721d36a448d24906000bb8
6ce15889c141c09ecf76a57795e91214a1f97648002710
dfc647c079757bac4f7776cc876746119ac451ea002710
是不是有点奇怪,函数原型中没有参数,那么参数从哪里获取呢?
实际上,我的方式是抛弃 solidity 编码,直接使用 assembly 来解析数据,代码如下
bytes memory input;
assembly {
let calldata_len := calldatasize()
let input_len := sub(calldata_len, 4)
input := mload(0x40)
mstore(input, input_len)
let input_data := add(input, 0x20)
calldatacopy(input_data, 4, input_len)
let free := add(input_data, input_len)
let free_round := and(add(free, 31), not(31))
mstore(0x40, free_round)
}
这里稍微解释下:
首先通过 calldatasize
得到调用数据的长度,减去 function selector 的 4 字节,得到的 input_len
即为参数长度
然后通过 0x40 获得空闲指针,拷贝参数到 memory
最后将参数长度按 32 字节向上取整,修改空闲指针
题外
不要觉得上面的 assembly 本身消耗了 gas,导致优化效果减少
要知道,即使按 Uniswap V3 传 bytes 参数的方式,也是需要拷贝数据到 memory,过程是一样的
如果考究一些,我们甚至可以跳过 solidity 编译后的某些 opcode
比如上面例子中,我并不检查 input_len 的长度是否大于0,因为我不需要
而 solidity 编译后的操作码,势必包括种种边界检查
换句话说,这种方式不仅优化了数据 gas,还稍微优化了一些 opcode
到此为止?
实际上,上面的优化有个小问题,在于 memory 中消耗了 32 字节用于保存 input 的长度,而这个长度,在整个生命周期中是固定的
我选择将它转移到栈上,只是使用时稍微麻烦一些,不像 bytes 方便~
代码如下
uint input;
uint input_len;
assembly {
let calldata_len := calldatasize()
input_len := sub(calldata_len, 4)
input := mload(0x40)
calldatacopy(input, 4, input_len)
let free := add(input, input_len)
let free_round := and(add(free, 31), not(31))
mstore(0x40, free_round)
}
实测
我用大概 100 多条套利路径,对 Uniswap V3 编码方式,以及进一步优化方式,分别跑了自动化测试,平均下来一笔交易可以优化 2000 gas 左右
比预期的优化大了很多,具体原因未查