笔者前两日学习了 Uniswap 的白皮书以及源码,具体学习笔记可看这篇博客。在阅读其源码的过程中,学习到了一些 Solidity 编程技巧,在此文记录分享。
在源码更新头寸(position)函数中,在从 storage 载入合约状态时,有如下额外注释(// SLOAD for gas optimization
):
function _modifyPosition(ModifyPositionParams memory params) {
// ...
Slot0 memory _slot0 = slot0; // SLOAD for gas optimization
position = _updatePosition(
params.owner,
params.tickLower,
params.tickUpper,
params.liquidityDelta,
_slot0.tick // 1
);
if (params.liquidityDelta != 0) {
if (_slot0.tick < params.tickLower) { // 2
// ...
} else if (_slot0.tick < params.tickUpper) { // 3
(slot0.observationIndex, slot0.observationCardinality) = observations.write( // 4
_slot0.observationIndex, // 5
_blockTimestamp(),
_slot0.tick, // 6
liquidityBefore,
_slot0.observationCardinality, // 7
_slot0.observationCardinalityNext // 8
);
amount0 = SqrtPriceMath.getAmount0Delta(
_slot0.sqrtPriceX96, // 9
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
amount1 = SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
_slot0.sqrtPriceX96, // 10
params.liquidityDelta
);
// ...
} else {
// ...
}
}
}
从代码中可见,由于函数内有 10 处需要引用 slot0
storage 变量内的字段。而访问 storage 变量(SLOAD)的成本是比访问 memory 变量(MLOAD)昂贵不少的。所以源码在函数最上方先使用一次 SLOAD 将变量 slot0
载入到内存中,以节省 Gas 开销。我们可以使用如下代码试验一下:
contract TestSaveToMem {
struct Slot {
uint256 a;
uint256 b;
}
Slot public slot;
constructor() {
slot = Slot({ a: 100, b: 200 });
}
function notSaveToMem() public returns (uint256 gasUsed) {
uint256 startGas = gasleft();
for (int i = 0; i < 10; i++) {
readProp(slot.a);
readProp(slot.b);
}
gasUsed = startGas - gasleft();
}
function saveToMem() public returns (uint256 gasUsed) {
uint256 startGas = gasleft();
Slot memory _slot = slot;
for (int i = 0; i < 10; i++) {
readProp(_slot.a);
readProp(_slot.b);
}
gasUsed = startGas - gasleft();
}
function readProp(uint256 prop) private pure {
require(prop > 0);
}
}
测试结果为:
notSaveToMem gasUsed: 18989
✓ notSaveToMem
saveToMem gasUsed: 4736
✓ saveToMem
可见函数如果单纯同样次数读取 storage 内的变量,先存入 memory 的话,开销大约只有 1/4 左右。
在源码创建代币交易对池的地方,我们可以看到:
function deploy(
address factory,
address token0,
address token1,
uint24 fee,
int24 tickSpacing
) internal returns (address pool) {
// ...
pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
// ...
}
代码在创建合约时,先将可预知的交易对池的元信息,通过 abi.encode
编码后,然后作为 salt
参数,在合约的创建中使用。在官方文档的解释中,若合约的创建中,指定了 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);
}
}
Uniswap 源码中,还原地址的逻辑也是类似的:
function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) {
require(key.token0 < key.token1);
pool = address(
uint256(
keccak256(
abi.encodePacked(
hex'ff',
factory,
keccak256(abi.encode(key.token0, key.token1, key.fee)),
POOL_INIT_CODE_HASH
)
)
)
);
}
如此一来,合约的地址就不需再上链进行抓取。
目前 ERC20 接口,对 transfer
等发送代币的函数的返回值定义会有两种情况,一种为若发送失败,则会返回 false
,如 openzeppelin 就是这么定义的,还有一种为若发送失败,则直接在函数中进行 revert()
,函数没有返回值。面对这种同一函数,同样参数,不同返回值的情况,Uniswap V3 源码是这么处理的:
function safeTransfer(
address token,
address to,
uint256 value
) internal {
(bool success, bytes memory data) =
token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), 'TF');
}
首先,由于 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 费的机制,做了一个实现,首先我们看一下交换代币函数的具体代码:
function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) external override noDelegateCall returns (int256 amount0, int256 amount1) {
// 计算出可交换出的另一种代币的数量
// ...
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
// ...
}
我们可以看到,swap
函数会要求请求它的合约,自身实现一个 IUniswapV3SwapCallback
的接口。函数在计算出可交换出的另一种代币的数量后,在返回之前,会先调用一下请求合约自身实现的 IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback
作为回调,并且最终可交换的 B 代币数会作为参数。而在抓取代币价格的合约 Quoter
中的具体实现为:
function quoteExactInputSingle(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountIn,
uint160 sqrtPriceLimitX96
) public override returns (uint256 amountOut) {
// ...
// 调用 swap 时,使用 try-catch 捕获 revert 信息
// 并从 parseRevertReason(reason) 中读取 reason 信息
try
getPool(tokenIn, tokenOut, fee).swap(
// ...
)
{} catch (bytes memory reason) {
return parseRevertReason(reason);
}
}
/// @inheritdoc IUniswapV3SwapCallback
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes memory path
) external view override {
// ...
// 将结果 amountReceived 存入 0x40 位置,然后作为 revert reason
assembly {
let ptr := mload(0x40)
mstore(ptr, amountReceived)
revert(ptr, 32)
}
// ...
}
function parseRevertReason(bytes memory reason) private pure returns (uint256) {
// ...
return abi.decode(reason, (uint256));
}
从代码中可见,首先使用了 try-catch
捕获 revert
,然后使用 assembly
读取 free memory pointer ,将汇率信息读出来。具体 assembly
的运用,可以参阅这篇文档。
所以说,虽然 quoteExactInputSingle
是 public
修饰的,但是其本质是一个 view
。
或许你还会问,虽然在一个 public
函数执行中途 revert()
的确能取消调用,并且退还 Gas 费,那也只是退还剩余部分,已被计算花销了的 Gas 并不会退还,那客户端不是还是要为了抓取一个汇率而付费吗?其实,在客户端(如 ethers)中,会使用 contract.callStatic.quoteExactInputSingle(…)
的方式,让节点以“假装”不会有状态变化(view
)的方式来尝试调用一个 public
函数,来达到没有 Gas 花费而又抓取了汇率的效果。
正如 ehters 的 callStatic 函数文档 中所描述的:
// 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 >