本文從copy自https://liaoph.com/uniswap-v3-3/
交易過程
v3 的 UniswapV3Pool 提供了比較底層的交易接口毁渗,而在 SwapRouter 合約中封裝了面向用戶的交易接口:
exactInput:指定交易對路徑,付出的 x token 數(shù)和預期得到的最小 y token 數(shù)(x, y 可以互換)
exactOutput:指定交易路徑跋理,付出的 x token 最大數(shù)和預期得到的 y token 數(shù)(x, y 可以互換)
這里我們講解 exactInput 這個接口斩披,調用流程如下:
路徑選擇
在進行兩個代幣交易時罚随,是首先需要在鏈下計算出交易的路徑兵钮,例如使用 ETH
-> DAI
:
- 可以直接通過
ETH/DAI
的交易池完成 - 也可以通過
ETH
->USDC
->DAI
路徑耐版,即經(jīng)過ETH/USDC
,USDC/DAI
兩個交易池完成交易
Uniswap 的前端會幫用戶實時計算出最優(yōu)路徑(即交易的收益最高)全景,作為參數(shù)傳給合約調用。前端中這部分計算的具體實現(xiàn)在這里骨望,具體過程為先用需要交易的輸入代幣硬爆,輸出代幣,以及一系列可用的中間代幣(代碼中叫 Base token)生成所有的路徑(當然為了降低復雜度擎鸠,路徑中最多包含3個代幣)缀磕,然后遍歷每個路徑輸出的輸出代幣數(shù)量,最后選取最佳路徑劣光。
事實上因為 v3 引入了費率的原因袜蚕,在路徑選擇的過程中還需要考慮費率的因素。關于交易結果的預計算绢涡,可以參考本文[末尾處]更新的內容牲剃。
交易入口
交易的入口函數(shù)是 exactInput 函數(shù),代碼如下:
struct ExactInputParams {
bytes path; // 路徑
address recipient; // 收款地址
uint256 deadline; // 交易有效期
uint256 amountIn; // 輸入的 token 數(shù)(輸入的 token 地址就是 path 中的第一個地址)
uint256 amountOutMinimum; // 預期交易最少獲得的 token 數(shù)(獲得的 token 地址就是 path 中最后一個地址)
}
function exactInput(ExactInputParams memory params)
external
payable
override
checkDeadline(params.deadline)
returns (uint256 amountOut)
{
// 通過循環(huán)雄可,遍歷傳入的路徑凿傅,進行交易
while (true) {
bool hasPools = params.path.hasPools();
// 完成當前路徑的交易
params.amountIn = exactInputSingle(
params.amountIn,
// 如果是中間交易,又合約代為收取和支付中間代幣
hasPools ? address(this) : params.recipient,
// 給回調函數(shù)用的參數(shù)
SwapData({
path: params.path.getFirstPool(),
payer: msg.sender
})
);
// 如果路徑全部遍歷完成数苫,則退出循環(huán)聪舒,交易完成
if (hasPools) {
// 步進 path 中的值
params.path = params.path.skipToken();
} else {
amountOut = params.amountIn;
break;
}
}
// 檢查交易是否滿足預期
require(amountOut >= params.amountOutMinimum, 'Too little received');
}
這里使用一個循環(huán)遍歷傳入的路徑,路徑中包含了交易過程中所有的 token文判,每相鄰的兩個 token 組成了一個交易對。例如當需要通過 ETH -> USDC -> DAI 路徑進行交易時室梅,會經(jīng)過兩個池:ETH/USDC 和 USDC/DAI戏仓,最終得到 DAI 代幣。如前所述亡鼠,這里其實還包含了每個交易對所選擇的費率赏殃。
路徑編碼/解碼
上面輸入的參數(shù)中 path 字段是 bytes 類型,通過這種類型可以實現(xiàn)更緊湊的編碼间涵。Uniswap 會將 bytes 作為一個數(shù)組使用仁热,bytes 類型就是一連串的 byte1,但是不會對每一個成員使用一個 word勾哩,因此相比普通數(shù)組其結構更加緊湊抗蠢。在 Uniswap V3 中, path 內部編碼結構如下圖:
圖中展示了一個包含 2個路徑(pool0, 和 pool1)的 path 編碼思劳。Uniswap 將編碼解碼操作封裝在了 Path 庫中迅矛,本文不再贅述其過程。每次交易時潜叛,會取出頭部的 tokenIn, tokenOut, fee秽褒,使用這三個參數(shù)找到對應的交易池壶硅,完成交易。
單個池的交易過程
單個池的交易在 exactInputSingle 函數(shù)中:
function exactInputSingle(
uint256 amountIn,
address recipient,
SwapData memory data
) private returns (uint256 amountOut) {
// 將 path 解碼销斟,獲取頭部的 tokenIn, tokenOut, fee
(address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
// 因為交易池只保存了 token x 的價格庐椒,這里我們需要知道輸入的 token 是交易池 x token 還是 y token
bool zeroForOne = tokenIn < tokenOut;
// 完成交易
(int256 amount0, int256 amount1) =
getPool(tokenIn, tokenOut, fee).swap(
recipient,
zeroForOne,
amountIn.toInt256(),
zeroForOne ? MIN_SQRT_RATIO : MAX_SQRT_RATIO,
// 給回調函數(shù)用的參數(shù)
abi.encode(data)
);
return uint256(-(zeroForOne ? amount1 : amount0));
}
交易分解
...
// 將交易前的元數(shù)據(jù)保存在內存中,后續(xù)的訪問通過 `MLOAD` 完成蚂踊,節(jié)省 gas
Slot0 memory slot0Start = slot0;
...
// 防止交易過程中回調到合約中其他的函數(shù)中修改狀態(tài)變量
slot0.unlocked = false;
// 這里也是緩存交易錢的數(shù)據(jù)乏梁,節(jié)省 gas
SwapCache memory cache =
SwapCache({
liquidityStart: liquidity,
blockTimestamp: _blockTimestamp(),
feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4)
});
// 判斷是否指定了 tokenIn 的數(shù)量
bool exactInput = amountSpecified > 0;
// 保存交易過程中計算所需的中間變量,這些值在交易的步驟中可能會發(fā)生變化
SwapState memory state =
SwapState({
amountSpecifiedRemaining: amountSpecified,
amountCalculated: 0,
sqrtPriceX96: slot0Start.sqrtPriceX96,
tick: slot0Start.tick,
feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,
protocolFee: 0,
liquidity: cache.liquidityStart
});
...
上面的代碼都是交易前的準備工作照筑,實際的交易在一個循環(huán)中發(fā)生:
// 只要 tokenIn
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
// 交易過程每一次循環(huán)的狀態(tài)變量
StepComputations memory step;
// 交易的起始價格
step.sqrtPriceStartX96 = state.sqrtPriceX96;
// 通過位圖找到下一個可以選的交易價格挫掏,這里可能是下一個流動性的邊界,也可能還是在本流動性中
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);
...
// 從 tick index 計算 sqrt(price)
step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);
// 計算當價格到達下一個交易價格時特纤,tokenIn 是否被耗盡军俊,如果被耗盡,則交易結束捧存,還需要重新計算出 tokenIn 耗盡時的價格
// 如果沒被耗盡粪躬,那么還需要繼續(xù)進入下一個循環(huán)
(state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
state.sqrtPriceX96,
(zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
? sqrtPriceLimitX96
: step.sqrtPriceNextX96,
state.liquidity,
state.amountSpecifiedRemaining,
fee
);
// 更新 tokenIn 的余額,以及 tokenOut 數(shù)量昔穴,注意當指定 tokenIn 的數(shù)量進行交易時镰官,這里的 tokenOut 是負數(shù)
if (exactInput) {
state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());
} else {
state.amountSpecifiedRemaining += step.amountOut.toInt256();
state.amountCalculated = state.amountCalculated.add((step.amountIn + step.feeAmount).toInt256());
}
...
// 按需決定是否需要更新流動性 L 的值
if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
// 檢查 tick index 是否為另一個流動性的邊界
if (step.initialized) {
int128 liquidityNet =
ticks.cross(
step.tickNext,
(zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
(zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128)
);
// 根據(jù)價格增加/減少,即向左或向右移動吗货,增加/減少相應的流動性
if (zeroForOne) liquidityNet = -liquidityNet;
secondsOutside.cross(step.tickNext, tickSpacing, cache.blockTimestamp);
// 更新流動性
state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
}
// 在這里更 tick 的值泳唠,使得下一次循環(huán)時讓 tickBitmap 進入下一個 word 中查詢
state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
} else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) {
// 如果 tokenIn 被耗盡,那么計算當前價格對應的 tick
state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
}
}
上面的代碼即交易的主循環(huán)宙搬,實現(xiàn)思路即以一個 tickBitmap 的 word 為最大單位笨腥,在此單位內計算相同流動性區(qū)間的交易數(shù)值,如果交易沒有完成勇垛,那么更新流動性的值脖母,進入下一個流動性區(qū)間計算,如果 tick index 移動到 word 的邊界闲孤,那么步進到下一個 word.
關于 tickBitmap 中下一個可用價格 tick index 的查找谆级,在函數(shù) TickBitmap 中實現(xiàn),這里不做詳細描述讼积。
拆分后的交易計算
交易是否能夠結束的關鍵計算在 SwapMath.computeSwapStep 中完成肥照,這里計算了交易是否能在目標價格范圍內結束,以及消耗的 tokenIn 和得到的 tokenOut. 這里摘取此函數(shù)部分代碼進行分析(這里僅摘取 exactIn 時的代碼):
function computeSwapStep(
uint160 sqrtRatioCurrentX96,
uint160 sqrtRatioTargetX96,
uint128 liquidity,
int256 amountRemaining,
uint24 feePips
)
internal
pure
returns (
uint160 sqrtRatioNextX96,
uint256 amountIn,
uint256 amountOut,
uint256 feeAmount
)
{
// 判斷交易的方向勤众,即價格降低或升高
bool zeroForOne = sqrtRatioCurrentX96 >= sqrtRatioTargetX96;
// 判斷是否指定了精確的 tokenIn 數(shù)量
bool exactIn = amountRemaining >= 0;
...
函數(shù)的輸入?yún)?shù)是當前價格建峭,目標價格,當前的流動性决摧,以及 tokenIn 的余額亿蒸。
if (exactIn) {
// 先將 tokenIn 的余額扣除掉最大所需的手續(xù)費
uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);
// 通過公式計算出到達目標價所需要的 tokenIn 數(shù)量凑兰,這里對 x token 和 y token 計算的公式是不一樣的
amountIn = zeroForOne
? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
: SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);
// 判斷余額是否充足,如果充足边锁,那么這次交易可以到達目標交易價格姑食,否則需要計算出當前 tokenIn 能到達的目標交易價
if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
// 當余額不充足的時候計算能夠到達的目標交易價
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
sqrtRatioCurrentX96,
liquidity,
amountRemainingLessFee,
zeroForOne
);
} else {
...
}
// 判斷是否能夠到達目標價
bool max = sqrtRatioTargetX96 == sqrtRatioNextX96;
// get the input/output amounts
if (zeroForOne) {
// 根據(jù)是否到達目標價格,計算 amountIn/amountOut 的值
amountIn = max && exactIn
? amountIn
: SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true);
amountOut = max && !exactIn
? amountOut
: SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false);
} else {
...
}
// 這里對 Output 進行 cap 是因為前面在計算 amountOut 時茅坛,有可能會使用 sqrtRatioNextX96 來進行計算音半,而 sqrtRatioNextX96
// 可能被 Round 之后導致 sqrt_P 偏大,從而導致計算的 amountOut 偏大
if (!exactIn && amountOut > uint256(-amountRemaining)) {
amountOut = uint256(-amountRemaining);
}
if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) {
// 如果沒能到達目標價贡蓖,即交易結束曹鸠,剩余的 tokenIn 將全部作為手續(xù)費
// 為了不讓計算進一步復雜化,這里直接將剩余的 tokenIn 將全部作為手續(xù)費
// 因此會多收取一部分手續(xù)費斥铺,即按本次交易的最大手續(xù)費收取
feeAmount = uint256(amountRemaining) - amountIn;
} else {
feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
}
交易收尾階段
我們再回到 swap 函數(shù)中循環(huán)檢查條件:
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
...
}
// 確定最終用戶支付的 token 數(shù)和得到的 token 數(shù)
(amount0, amount1) = zeroForOne == exactInput
? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
: (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);
// 扣除用戶需要支付的 token
if (zeroForOne) {
// 將 tokenOut 支付給用戶彻桃,前面說過 tokenOut 記錄的是負數(shù)
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
uint256 balance0Before = balance0();
// 還是通過回調的方式,扣除用戶需要支持的 token
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
// 校驗扣除是否成功
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
...
}
// 記錄日志
emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.tick);
// 解除防止重入的鎖
slot0.unlocked = true;
}
這里還是通過回調完成用戶支付 token 的費用晾蜘。因為發(fā)送用戶 token 是在回調函數(shù)之前完成的邻眷,因此這個 swap 函數(shù)是可以被當作 flash swap 來使用的。
需要注意剔交,如果本次交易是交易路徑中的一次中間交易肆饶,那么扣除的 token 是從 SwapRouter 中扣除的,交易完成獲得的 token 也會發(fā)送給 SwapRouter 以便其進行下一步的交易岖常,我們回到 SwapRouter 中的 exactInput 函數(shù):
params.amountIn = exactInputSingle(
params.amountIn,
// 這里會判斷是否是最后一次交易驯镊,當是最后一次交易時,獲取的 token 的地址才是用戶的指定的地址
hasPools ? address(this) : params.recipient,
SwapData({
path: params.path.getFirstPool(),
payer: msg.sender
})
);
再來看一下支付的回調函數(shù):
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes calldata _data
) external override {
SwapData memory data = abi.decode(_data, (SwapData));
(address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);
// 這里有點繞竭鞍,目的就是判斷函數(shù)的參數(shù)中哪個是本次支付需要支付的代幣
(bool isExactInput, uint256 amountToPay) =
amount0Delta > 0
? (tokenIn < tokenOut, uint256(amount0Delta))
: (tokenOut < tokenIn, uint256(amount1Delta));
if (isExactInput) {
// 調用 pay 函數(shù)支付代幣
pay(tokenIn, data.payer, msg.sender, amountToPay);
} else {
...
}
}
回調完成后板惑,swap 函數(shù)會返回本次交易得到的代幣數(shù)量。exactInput 將判斷是否進行下一個路徑的交易笼蛛,直至所有的交易完成洒放,進行輸入約束的檢查:
require(amountOut >= params.amountOutMinimum, 'Too little received');
如果交易的獲得 token 數(shù)滿足約束蛉鹿,則本次交易結束滨砍。
本文僅對 exactInput 這一種交易情況進行了分析,理解了這個交易的整個流程后妖异,就可以觸類旁通理解 exactOutput 的交易過程惋戏。
交易預計算
當用戶和 uniswap 前端進行交互時,前端需要預先計算出用戶輸入 token 能夠預期得到的 token 數(shù)量他膳。
這個功能在 uniswap v2 有非常簡單的實現(xiàn)响逢,只需要查詢處合約中兩個代幣的余額就可以完成預計算。
但是在 v3 版本中棕孙,由于交易的計算需要使用合約內的 tick 信息舔亭,預計算只能由 uniswap v3 pool 合約來完成些膨,但是 pool 合約中的計算函數(shù)都是會更改合約狀態(tài)的 external
函數(shù),那么如何把這個函數(shù)當作 view/pure
函數(shù)來使用呢钦铺?uniswap v3 periphery 倉庫中給出了一個非常 tricky 的實現(xiàn)订雾,代碼在 contracts/lens/Quoter.sol
中:
function quoteExactInputSingle(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountIn,
uint160 sqrtPriceLimitX96
) public override returns (uint256 amountOut) {
bool zeroForOne = tokenIn < tokenOut;
try
getPool(tokenIn, tokenOut, fee).swap( // 調用 pool 合約的 swap 接口來模擬一次真實的交易
address(this), // address(0) might cause issues with some tokens
zeroForOne,
amountIn.toInt256(),
sqrtPriceLimitX96 == 0
? (zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1)
: sqrtPriceLimitX96,
abi.encodePacked(tokenIn, fee, tokenOut)
)
{} catch (bytes memory reason) {
return parseRevertReason(reason);
}
}
可以看到函數(shù)中調用了 getPool(tokenIn, tokenOut, fee).swap(),即 pool 合約的真實交易函數(shù)矛洞,但是實際上我們并不想讓交易發(fā)生洼哎,這個交易調用必定也會失敗,因此合約使用了 try/catch 的方式捕獲錯誤沼本,并且在回調函數(shù)中獲取到模擬交易的結果噩峦,存入內存中。
可以看回調函數(shù):
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes memory path
) external view override {
require(amount0Delta > 0 || amount1Delta > 0); // swaps entirely within 0-liquidity regions are not supported
(address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool();
CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);
(bool isExactInput, uint256 amountToPay, uint256 amountReceived) =
amount0Delta > 0
? (tokenIn < tokenOut, uint256(amount0Delta), uint256(-amount1Delta))
: (tokenOut < tokenIn, uint256(amount1Delta), uint256(-amount0Delta));
if (isExactInput) {
assembly { // 這里代碼需要將結果保存在內存中
let ptr := mload(0x40) // 0x40 是 solidity 定義的 free memory pointer
mstore(ptr, amountReceived) // 將結果保存起來
revert(ptr, 32) // revert 掉交易抽兆,并將內存中的數(shù)據(jù)作為 revert data
}
} else {
// if the cache has been populated, ensure that the full output amount has been received
if (amountOutCached != 0) require(amountReceived == amountOutCached);
assembly {
let ptr := mload(0x40)
mstore(ptr, amountToPay)
revert(ptr, 32)
}
}
}
這個回調函數(shù)主要的作用就是將 swap()
函數(shù)計算處的結果保存到內存中识补,這里使用了 assembly 來訪問 solidity 的 free memory pointer,關于 solidity 內存布局郊丛,可以參考文檔:Layout in Memory.
將結果保存到內存中時候就將交易 revert
掉李请,然后在 quoteExactInputSingle
中捕獲這個錯誤,并將內存中的信息讀取出來厉熟,返回給調用者:
/// @dev Parses a revert reason that should contain the numeric quote
function parseRevertReason(bytes memory reason) private pure returns (uint256) {
if (reason.length != 32) { // swap 函數(shù)正常 revert 的情況
if (reason.length < 68) revert('Unexpected error');
assembly {
reason := add(reason, 0x04)
}
revert(abi.decode(reason, (string)));
}
return abi.decode(reason, (uint256)); // 這里捕獲前面回調函數(shù)保存在內存中的結果导盅。
}
總結:通過 try/catch
結合回調函數(shù),模擬計算結果揍瑟,實現(xiàn)了交易預計算的功能白翻,這樣 uniswap 前端就能夠在獲取用戶輸入后進行交易的預計算了,這部分前端的實現(xiàn)在這里绢片。