本文將深入分析 Uniswap v3 協定的核心機制及功能設計,提供相關審計要點。

作者:Sissice,慢霧安全團隊

編輯:Liz

封面:Uniswap

前言

隨著去中心化金融(DeFi)的快速發展,Uniswap 作為領先的去中心化交易所一直走在創新的前沿。 本文將深入分析 Uniswap v3 協定的核心機制,並詳細解讀其功能設計,包括集中流動性、多重費率、代幣兌換及閃電貸等關鍵功能,同時為審計人員提供相關的審計要點。(注:本文中的圖片可在 https://www.figma.com/board/QyIpAUR93MxZ4XZZf2QjDk/uniswap-v3 查看高清版,點擊閱讀原文可直接跳轉。)

  架構簡析

 Uniswap v3 協定主要由四個模組組成:

  • PositionManager:使用者進行流動性操作的主要介面,用戶可以通過它創建代幣池、提供/移除流動性,並使用 ERC721 作為流動性提供者(LP)的憑證。
  • SwapRouter:使用者進行代幣交換的入口,用戶可以通過該模組完成代幣的交換操作。
  • Pool:負責實現代幣交易、流動性管理、收取交易手續費,以及 Oracle 數據的管理功能。 其中,Tick 機制將價格範圍劃分為多個精細的刻度。
  • Factory:用於創建和管理 Pool 合約。

流程梳理

  創建代幣對

  提供流動性

用戶可以通過 mint 函數創建新的流動性頭寸並生成對應的 NFT,或通過 increaseLiquidity 函數為現有的 NFT 流動性頭寸增加流動性。 首先,系統會檢查交易是否在規定的時間範圍內執行,然後調用 addLiquidity 函數完成具體操作。 在該函數中,首先計算出池子的位址和流動性的大小,接著調用 _updatePosition 更新使用者的 Position,修改 lower、upper tick 以及累計的手續費總額。 隨後,系統通過 _modifyPosition 添加流動性,確保 tick 滿足上下限條件,返回計算出的 token0 和 token1 數量(int256),並將其發送到池中。 最後,系統根據使用者的 tokenId 更新對應的 Position 資訊。

  拿掉流動性

用戶可以通過 decreaseLiquidity 函數來移除流動性。 首先,系統會檢查 LP 憑證的許可權以及交易的時間有效性。 在確保池子擁有足夠流動性的前提下,調用 burn 函數來移除流動性。 隨後,系統會核實實際移除的代幣數量是否滿足用戶設定的最小限度要求,並相應地更新使用者的 Position 資訊。

swap

用戶可以通過 exactInput 函數指定支付的 token 數量以及期望獲得的最小 token 數量,或通過 exactOutput 函數指定支付的最大 token 數量並設定期望獲得的 token 數量。 系統首先解析路徑(path),然後依次調用 exactInputInternal 或 exactOutputInternal 函數完成每一步的 swap 操作。

在 swap 函數中,系統首先鎖定 unlocked 狀態,防止其他交易干擾狀態變數的更新。 進入迴圈后,系統通過 tick 找到下一個交易價格,並調用 computeSwapStep 函數計算每一步的交換,直到 tokenIn 或 tokenOut 達到用戶預期。 同時,系統會更新手續費、流動性、tick 以及價格的相關值。 如果 tick 發生變化,還需要更新 Oracle 數據。 完成這些操作后,系統將 tokenOut 支付給使用者,使用者再通過回調函數 uniswapV3SwapCallback 支付 tokenIn,這種機制可以被視為一種閃電交換(flash swap)。 隨後,系統會檢查合約餘額是否匹配,並在確認無誤後解鎖 unlocked 狀態。

當路徑中的所有 swap 操作都完成,且交易符合用戶的預期時,交易即成功結束。

flash

用戶可以通過 flash 函數來進行閃電貸操作。 首先,系統會計算借貸的手續費,然後將使用者所需的 token 發送到指定的借貸位址。 接下來,系統回調用戶實現的 uniswapV3FlashCallback 函數,使用者在此函數中完成還款操作。 系統會在回調后檢查合約餘額的變化,確保其與使用者借貸的數量相符,同時更新相應的手續費。 除了 flash 函數,使用者也可以通過 swap 操作實現類似的閃電貸功能,即在交易過程中先借入再償還 token。

  審計要點

1. 檢查 swap 操作後是否有調用 refundETH

在 exactInput 函數中,使用者需要指定支付的 token 數量和預期獲得的最小 token 數量。 在調用 uniswapV3SwapCallback 之前,系統會重新計算 amount0 和 amount1,以確保使用者可以精確地發送 token。 然而,當使用 ETH 進行交換時,使用者需要隨交易一起發送 ETH。 即便在交易過程中未使用完所有的 ETH,函數不會自動退回多餘部分。 exactInput 函數僅返回 amountOut,因此交易者無法直接得知此次交換實際消耗了多少 ETH。

此外,任何人都可以調用 refundETH 函數,從合約中提取未使用的 ETH。 因此,建議檢查 swap 操作後是否調用 refundETH 以防止使用者未使用的 ETH 遺留在協定中,或使用 MultiCall 函數在一次操作中完成多個函數的調用。

function refundETH() external payable override {
        if (address(this).balance > 0) TransferHelper.safeTransferETH(msg.sender, address(this).balance);
    }

 2. 檢查是否實現 TWAP 來獲取預言機價格

當將 Uniswap 作為價格來源時,外部協定直接訪問 Slot0 獲取 sqrtPriceX96 可能存在價格操縱的風險。 攻擊者能通過 swap 等方式操縱流動性池的狀態,從而在執行交易時獲得有利的價格。

為了降低這種風險,建議開發者進一步實現時間加權平均價格(TWAP)來獲取價格,因為 TWAP 能有效減少短期內價格的劇烈波動影響,使操縱價格的難度增加。

function observe(
        Observation[65535] storage self,
        uint32 time,
        uint32[] memory secondsAgos,
        int24 tick,
        uint16 index,
        uint128 liquidity,
        uint16 cardinality
    ) internal view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) {
        require(cardinality > 0, 'I');

        tickCumulatives = new int56[](secondsAgos.length);
        secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length);
        for (uint256 i = 0; i < secondsAgos.length; i++) {
            (tickCumulatives[i], secondsPerLiquidityCumulativeX128s[i]) = observeSingle(
                self,
                time,
                secondsAgos[i],
                tick,
                index,
                liquidity,
                cardinality
            );
        }
    }

 3. 建議允許使用者自行設置滑點參數

當其他協定使用 Uniswap v3 進行 swap 操作時,建議開發者根據業務場景設置滑點保護,並允許使用者自行調整參數,以防止遭受三明治攻擊。 在此 swap 函數中,第四個參數 sqrtPriceLimitX96 用於指定用戶願意執行交換的最低或最高價格。 這一參數可有效防止在交易過程中價格出現極端波動,從而降低使用者因滑點過大而產生的損失。

function swap(
        address recipient,
        bool zeroForOne,
        int256 amountSpecified,
        uint160 sqrtPriceLimitX96,
        bytes calldata data
    ) external override noDelegateCall returns (int256 amount0, int256 amount1) {
    ...
}

 4. 建議引入流動性池白名單機制

在 Uniswap v3 中,基於不同的手續費(fee),同一對 ERC20 代幣可能同時存在多個流動性池(Pool)。 通常,少數流動性池擁有絕大部分的流動性,而其他池的總鎖倉量(TVL)可能非常少,甚至尚未創建。 這些 TVL 較低的池更容易成為價格操縱的目標。

因此,專案方在選擇使用流動性池數據時,應該避免簡單地以 LP 為數據源。 為確保數據的可靠性,建議引入白名單機制,篩選出流動性充足且較難操縱的池。 這種機制可以顯著降低風險,確保價格引用數據的安全性和準確性,同時防止因 TVL 過低的池被操縱而引發的潛在損失。

function createPool(
        address tokenA,
        address tokenB,
        uint24 fee
) external override noDelegateCall returns (address pool) {
        require(tokenA != tokenB);
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0));
        int24 tickSpacing = feeAmountTickSpacing[fee];
        require(tickSpacing != 0);
        require(getPool[token0][token1][fee] == address(0));
        pool = deploy(address(this), token0, token1, fee, tickSpacing);
        getPool[token0][token1][fee] = pool;
        // populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses
        getPool[token1][token0][fee] = pool;
        emit PoolCreated(token0, token1, fee, tickSpacing, pool);
    }

5. 檢查是否在 TickMath.sol、FullMath.sol 和 Position.sol 中使用 unchecked

TickMath、FullMath 和 Position 等模組在 Uniswap v3 中用於執行複雜的數學計算,這些計算依賴於 Solidity 中的溢出處理機制。 在早期的 Solidity 版本(<0.8.0)中,整數溢出和下溢行為默認不拋出異常,因此代碼可以基於這種假設進行正常運行。 然而,自 Solidity 0.8.0 版本開始,溢出和下溢會自動拋出異常,這會影響現有代碼的執行。 為確保這些模組在 Solidity 0.8.0 及更高版本中正常運行,開發者需要在特定函數中使用 unchecked 代碼塊,手動禁用溢出檢查。 這可以恢復之前版本中的行為,並確保高效執行溢出敏感的運算。

官方已經針對 Solidity 0.8.0 及更高版本做了相應的支持和調整,詳情可參見此更新(https://github.com/Uniswap/v3-core/commit/6562c52e8f75f0c10f9deaf44861847585fc8129)。 這一改動確保在新版編譯器下,TickMath、FullMath 和其他相關模組能夠繼續正確運行。

 6. 檢查 path 編碼解碼方式是否相同

在 Uniswap v3 的 exactInput 和 exactOutput 函數中,使用者需要輸入 path 參數,該路徑必須按照固定格式進行編碼和解碼,即 tokenA-fee-tokenB,用於逐步進行代幣交換操作。 這個路徑結構明確指定了每一跳交易中涉及的兩個代幣以及它們之間的手續費級別。 如果外部協定在使用 Uniswap v3 的代幣交換功能時選擇了不同的路徑解碼方式,可能會導致與 Uniswap 預期的路徑格式不符。 這種情況下,協定可能無法正確解析路徑,從而無法成功執行預期的代幣交換操作。

因此,建議開發者在集成 Uniswap v3 的代幣交換功能時,確保外部協定嚴格遵循 Uniswap 的路徑編碼規則。 為防止出現路徑解碼錯誤,外部協定應在調用 exactInput 和 exactOutput 時,仔細檢查 path 參數的格式,以避免交易失敗或獲得意外的結果。

function decodeFirstPool(bytes memory path)
        internal
        pure
        returns (
            address tokenA,
            address tokenB,
            uint24 fee
        )
    {
        tokenA = path.toAddress(0);
        fee = path.toUint24(ADDR_SIZE);
        tokenB = path.toAddress(NEXT_OFFSET);
    }

 7. 檢查代幣順序是否影響項目邏輯

在 Uniswap 中,token0 是排序順序較低的代幣,用作基礎代幣(base token),而 token1 是排序順序較高的代幣,用作報價代幣(quote token)。 Uniswap 會根據兩個代幣的位址按字典序進行排序,確保代幣對的順序在池子中始終保持一致。

然而,由於同一代幣在不同區塊鏈網路上的合約位址可能不同,尤其是跨鏈部署的合約,代幣的排序順序可能會發生變化。 這種變化會導致 token0 和 token1 的角色互換,從而影響價格表現。 例如,在某些鏈上,特定代幣可能是 token0,但在其他鏈上,它可能被排序為 token1,導致基礎代幣和報價代幣的關係不同,最終影響顯示的價格。 因此,建議開發者檢查代幣順序是否會影響項目邏輯,特別是在跨鏈環境中,務必考慮代幣順序可能導致的價格問題,以避免對價格表現和交易邏輯產生不利影響。

(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);

  總結

上述基礎檢查項基於 Uniswap v3 當前版本,供審計人員對與 Uniswap v3 有交互的專案進行檢查。 不同項目的實現各具特點,因此審計人員需深入理解協定,並根據實際情況進行嚴格檢查。 對於正在開發的專案,慢霧安全團隊建議開發者在開發過程中認真考慮這些檢查項,以確保協定的安全性和可靠性。

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。