代码在创建合约时,先将可预知的交易对池的元信息,通过 abi.encode 编码后,然后作为 slat 参数,在合约的创建中使用。在官方文档的解释中,若合约的创建中,指定了 salt 参数,则会使用 create2 机制创建合约,即若指定的 salt 不变,则创建的合约地址不变,只需使用 bytes1(0xff),工厂合约的地址,salt,合约代码的哈希以及初始化参数,就可还原。
官方的例子如下:
contract D {
uint public x;
constructor(uint a) {
x = a;
}
}
contract C {
function createDSalted(bytes32 salt, uint arg) public {
address predictedAddress = address(uint160(uint(keccak256(abi.encodePacked(
bytes1(0xff),
address(this),
salt,
keccak256(abi.encodePacked(
type(D).creationCode,
arg
))
)))));
D d = new D{salt: salt}(arg);
require(address(d) == predictedAddress);
}
}
所以说,虽然 quoteExactInputSingle 是 public 修饰的,但是其本质是一个 view 。
或许你还会问,虽然 revert() 函数的确能取消调用,并且退还 Gas 费,那也只是退还剩余部分,已被计算花销了的 Gas 并不会退还,那客户端不是还是要为了抓取一个汇率而付费吗?其实,在客户端(如 ethers)中,会使用 contract.callStatic.quoteExactInputSingle(…) 的方式,让节点以“假装”不会有状态变化的方式来尝试调用一个 public 函数,来达到没有 Gas 花费而又抓取了汇率的效果。
// Rather than executing the state-change of a transaction, it is possible to ask a node to
// pretend that a call is not state-changing and return the result.
// This does not actually change any state, but is free. This in some cases can be used
// to determine if a transaction will fail or succeed.
contract.callStatic.METHOD_NAME( ...args [ , overrides ] ) ⇒ Promise< any >
笔者前两日学习了 Uniswap 的白皮书以及源码,具体学习笔记可看这篇博客。在阅读其源码的过程中,学习到了一些 Solidity 编程技巧,在此文记录分享。
MLOAD vs SLOAD
在源码更新头寸(position)函数中,在从 storage 载入合约状态时,有如下额外注释(
// SLOAD for gas optimization
):从代码中可见,由于函数内有 10 处需要引用
slot0
storage 变量内的字段。而访问 storage 变量(SLOAD)的成本是比访问 memory 变量(MLOAD)昂贵不少的。所以源码在函数最上方先使用一次 SLOAD 将变量slot0
载入到内存中,以节省 Gas 开销。我们可以使用如下代码试验一下:测试结果为:
可见函数如果单纯同样次数读取 storage 内的变量,先存入 memory 的话,开销大约只有 1/4 左右。
可预知的合约地址
在源码创建代币交易对池的地方,我们可以看到:
代码在创建合约时,先将可预知的交易对池的元信息,通过
abi.encode
编码后,然后作为slat
参数,在合约的创建中使用。在官方文档的解释中,若合约的创建中,指定了salt
参数,则会使用create2
机制创建合约,即若指定的salt
不变,则创建的合约地址不变,只需使用bytes1(0xff)
,工厂合约的地址,salt
,合约代码的哈希以及初始化参数,就可还原。官方的例子如下:
Uniswap 源码中,还原地址的逻辑也是类似的:
如此一来,合约的地址就不需再上链进行抓取,一方面方便了部署,另一方也节省了存储与抓取的 Gas 开销。
不一致的 ERC20 接口定义
由于一些争论,目前 ERC20 接口,对
transfer
等发送代币的函数的返回值定义有两种,一种为若发送失败,则会返回false
,如 openzeppelin 就是这么定义的,还有一种为若发送失败,则直接在函数中进行revert()
,函数没有返回值。面对这种同一函数,同样参数,不同返回值的情况,Uniswap V3 源码是这么处理的:首先,由于 Solidity 的 Function Selector 定义中可知,Function Selector 是函数原型(prototype)哈希的前 4 字节,而函数原型是由函数名和它的所有参数类型决定的。所以
abi.encodeWithSelector
可以允许代码在未知函数返回类型的情况下,调用函数。在调用之后,代码通过第二个返回值data
来判断返回布尔版本的transfer
是否调用成功,第一个返回值bool success
来判断revert()
版本的调用成功(若调用成功就无返回值即data.length == 0
)与否。“预计算”
由于在 Uniswap V3 中,同一种代币对的交换,可能同时存在许许多多不同价格区间的流动性池,所以,在查询当前给定的 A 代币能交换多少 B 代币(即代币的价格)时,并不能像 V2 那样,抓取一下两边代币在流动性池中的数量(仅需要调用一下
view
级别的函数),客户端利用核心公式x * y = (x + Δx) * (y − Δy) = k
一推导,就能够知道。所以在 V3 中,只能像实际交换代币那样,从当前价格开始,一个个头寸池流过去,才能计算出最终可以得到的 B 代币数量。而在 V3 源码种,交换代币函数是一个接近 200 行的大函数,且会改变合约的状态,若用户刚想知道一下代币间的汇率,就要支付 Gas 费,也是不合理的。在这种情况下, Uniswap V3 利用了 solidity 中,revert(string reason)
函数可以终止当前函数调用,并向调用者退还剩余 Gas 费的机制,做了一个实现,首先我们看一下交换代币函数的具体代码:我们可以看到,
swap
函数会要求请求它的合约,自身实现一个IUniswapV3SwapCallback
的接口。函数在计算出可交换出的另一种代币的数量后,在返回之前,会先调用一下请求合约自身实现的IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback
作为回调,并且最终可交换的 B 代币数会作为参数。而在抓取代币价格的合约Quoter
中的具体实现为:从代码中可见,首先使用了
try-catch
捕获revert
,然后使用assembly
读取 free memory pointer ,将汇率信息读出来。具体assembly
的运用,可以参阅这篇文档。所以说,虽然
quoteExactInputSingle
是public
修饰的,但是其本质是一个view
。或许你还会问,虽然
revert()
函数的确能取消调用,并且退还 Gas 费,那也只是退还剩余部分,已被计算花销了的 Gas 并不会退还,那客户端不是还是要为了抓取一个汇率而付费吗?其实,在客户端(如 ethers)中,会使用contract.callStatic.quoteExactInputSingle(…)
的方式,让节点以“假装”不会有状态变化的方式来尝试调用一个public
函数,来达到没有 Gas 花费而又抓取了汇率的效果。正如 ehters 的 callStatic 函数文档 中所描述的: