Uniswap v3 詳解(五):Oracle 預言機

本文copy自https://liaoph.com/uniswap-v3-5/

Uniswap v2 的 Oracle

1637903042(1).png

Uniswap v3 的 Oracle

Uniswap v3 版本針對 v2 版本 Oracle 的痛點,進行了改進:

合約中默認還是存儲一個最近價格的時間累積值,但是可以根據(jù)需要,擴展為存儲最近 N 個歷史價格的時間累積值,最多支持 65535 個最近歷史價格信息(還可以包含當前未被寫入的價格信息,這樣就是 65536 個值),這樣第三方開發(fā)者不再需要自己實現(xiàn)合約存儲歷史信息
Oracle 中不光記錄了價格信息,還記錄了對應流動性的時間累積值,因為 v3 中相同交易對在不同費率時時不同的交易池,這樣在使用 Oracle 時,可以選擇流動性較大的池最為價格參考來源
Uniswap v2 中可以計算出時間加權(quán)平均價格(算術(shù)平均值),而 v3 中計算出來的是時間加權(quán)價時幾何平均值,團隊稱幾何平均值比算術(shù)平均值更適合,但我個人認為這個主要是考慮到工程實現(xiàn)的復雜度所做出的改變

算術(shù)平均值與幾何平均值

1637903106(1).png

為什么使用幾何平均值

算術(shù)平均數(shù)的優(yōu)勢是其簡單性,也是最符合直覺的平均數(shù)。當用于計算算術(shù)平均數(shù)的數(shù)據(jù)系列越長,其算術(shù)平均值就有越高的機率接近期望值,這種現(xiàn)象在統(tǒng)計學中被稱為「大數(shù)法則」。

幾何平均數(shù)一般來說都會小于算術(shù)平均數(shù),因此但是對于波動較大的數(shù)字而言,使用幾何平均數(shù),其受波動性的影響會更小。

具體到 Uniswap 項目中:

  • 從工程實現(xiàn)角度看

  • 合約中使用 tick index 來表示價格區(qū)間,存儲 tick index 的時間加權(quán)累積值,使得 Oracle 的實現(xiàn)更為簡單直接,避免了在存儲 Oracle 數(shù)據(jù)過程中進行指數(shù)計算,在計算時可以更節(jié)省 gas

  • 在 v2 版本中,需要對 token0 和 token1 的 Oracle 分別進行存儲(因為價格的算術(shù)平均數(shù)并不是互為倒數(shù)的)。而 v3 使用了幾何平均數(shù),token0 和 token1 的價格的幾何平均數(shù)互為倒數(shù),這樣合約只存儲代幣對中一個代幣的的 Oracle 值即可,在存儲時也更加節(jié)省 gas 費用

  • 從金融數(shù)學角度看

  • 交易對價格的走勢可以通過幾何布朗運動模型化,在幾何布朗運動中算術(shù)平均數(shù),會給予高價格更高的權(quán)重,平均價格更容易受到波動性的影響。

攻擊成本

一個攻擊者在攻擊代幣對的 Oracle 時,為了成本最小化,通常會采用以下策略:

攻擊者在同一個區(qū)塊中,在 Oracle 寫入的前后先買入再賣出某種資產(chǎn),以實現(xiàn)低成本操縱 Oracle 數(shù)據(jù)的目的

防御措施

Uniswap v3 沿用了一些 v2 版本中的設(shè)計,以增加對 Oracle 進行攻擊的成本。具體如下:

在同一個區(qū)塊內(nèi),Oracle 的寫入操作只會發(fā)生在這個區(qū)塊中的第一筆對價格發(fā)生了改變的交易中
在寫入 Oracle 時,寫入的并不是本次交易結(jié)束時的價格,而是交易前的價格,即上一次交易的最終價格。又因為第一條的原因,這樣實際上在更新本次 Oracle 數(shù)據(jù)時使用的是上一次交易所在區(qū)塊中最后一個交易的價格。
這樣做之后:

攻擊者無法在一個區(qū)塊內(nèi)完成對 Oracle 數(shù)據(jù)的操縱,只能分多筆交易跨區(qū)塊才有可能實現(xiàn) Oracle 數(shù)據(jù)的操縱
多筆跨區(qū)塊的交易還必須要發(fā)生在對應區(qū)塊的末尾和起始交易,這也是很難實現(xiàn)的
這些限制無疑增加了攻擊者對代幣對進行攻擊的成本,使得低成本攻擊變得困難。

當然,隨著 flashbots/MiningDAO 之類的工具的流行,這種攻擊也不是不可能實現(xiàn),但是這是另外的話題了,不在本文討論范圍內(nèi)。

代碼實現(xiàn)

Oracle 實現(xiàn)的代碼都在 uniswap-v3-core 的合約中實現(xiàn)。Oracle 相關(guān)的操作主要在以下情況下發(fā)生:

初始化交易池時,初始化 Oracle,但是此時 Oracle 中只有一個槽位,即只保存最近的一份數(shù)據(jù)
發(fā)生交易時,價格會變動,此時需要更新 Oracle
外部開發(fā)者可以調(diào)用接口獲取歷史 Oracle 數(shù)據(jù),計算出 TWAP
對交易對歷史 Oracle 數(shù)據(jù)由需求的開發(fā)者可以調(diào)用交易池提供的接口擴展 Oracle 數(shù)據(jù)存儲的數(shù)量

存儲

Oracle 數(shù)據(jù)使用一個結(jié)構(gòu)體 Observation 來表示:

struct Observation {
    // 記錄區(qū)塊的時間戳
    uint32 blockTimestamp;
    // tick index 的時間加權(quán)累積值
    int56 tickCumulative;
    // 價格所在區(qū)間的流動性的時間加權(quán)累積值
    uint160 liquidityCumulative;
    // 是否已經(jīng)被初始化
    bool initialized;
}

在交易池的合約中,使用一個數(shù)組來存儲交易池最近的的 Oracle 數(shù)據(jù):

contract UniswapV3Pool is IUniswapV3Pool, NoDelegateCall {
    ...
    // Oracle 相關(guān)操作的庫
    using Oracle for Oracle.Observation[65535];
    ...
    // 使用數(shù)據(jù)記錄 Oracle 的值
    Oracle.Observation[65535] public override observations;
    ...
    struct Slot0 {
        ...
        // 記錄了最近一次 Oracle 記錄在 Oracle 數(shù)組中的索引位置
        uint16 observationIndex;
        // 已經(jīng)存儲的 Oracle 數(shù)量
        uint16 observationCardinality;
        // 可用的 Oracle 空間,此值初始時會被設(shè)置為 1,后續(xù)根據(jù)需要來可以擴展
        uint16 observationCardinalityNext;
    }
    Slot0 public override slot0;
}

數(shù)組的大小為 65535,但是實際上在初始階段這個數(shù)據(jù)并不會被全部使用,而僅使用其中一部分空間(初始為 1)。這樣做的目的是,如果沒有必要,那么僅存儲最近一份 Oracle 數(shù)據(jù)即可,因為寫入數(shù)據(jù)到數(shù)組中需要比較高昂的 gas 費用(SSTORE 操作)。

當?shù)谌綄δ硞€交易池的 Oracle 有需求時,可以主動調(diào)用合約的接口擴展這個數(shù)據(jù)的可用空間,這樣后續(xù)合約會存儲更多的 Oracle 數(shù)據(jù)。

同時在這個接口中,會對數(shù)組中數(shù)據(jù)進行寫入,這樣后續(xù)交易發(fā)生時,不需要進行存儲空間的擴展,而僅需要更新其中的值。這樣也將擴展 Oracle 空間需要的手續(xù)費從代幣交易者轉(zhuǎn)移到了 Oracle 的實際需求方。這方面內(nèi)容會在[后文]介紹。

當數(shù)組可用大小寫滿之后,它會重新從 0 開始寫入,即使用上類似一個 ring buffer.

初始化

在之前創(chuàng)建交易對的文章中說過,創(chuàng)建交易對時,UniswapV3Factory 會調(diào)用新創(chuàng)建交易對的 UniswapV3Pool.initialize() 函數(shù)對合約進行初始化:

function initialize(uint160 sqrtPriceX96) external override {
    ...

    // 初始化 Oracle
    (uint16 cardinality, uint16 cardinalityNext) = observations.initialize(_blockTimestamp());

    ...
}

在初始化的代碼中,調(diào)用了 observations.initialize(_blockTimestamp()) 來進行 Oracle 的初始化:

function initialize(Observation[65535] storage self, uint32 time)
    internal
    returns (uint16 cardinality, uint16 cardinalityNext)
{
    self[0] = Observation({blockTimestamp: time, tickCumulative: 0, liquidityCumulative: 0, initialized: true});
    // 返回當前 Oracle 的個數(shù)和最大可用個數(shù)
    return (1, 1);
}

可以看到初始化就是寫入一個空值 Oracle 數(shù)據(jù),并且返回了當前 Oracle 的個數(shù)和最大可用個數(shù)。最大可用個數(shù)即 cardinalityNext 為 1,表示合約初始化時,只會記錄最近的一次 Oracle 數(shù)據(jù)。

Oracle 更新

Oracle 數(shù)據(jù)的更新發(fā)生在價格變動的時候,前文說過,為了防止攻擊,同一個區(qū)塊內(nèi),只會在第一次發(fā)生價格變動時寫入 Oracle 信息。在 UniswapV3Pool.swap() 函數(shù)中:

// 檢查價格是否發(fā)生了變化,當價格變化時,需要寫入一個 Oracle 數(shù)據(jù)
if (state.tick != slot0Start.tick) {
    // 寫入 Oracle 數(shù)據(jù)
    (uint16 observationIndex, uint16 observationCardinality) =
        observations.write(
            // 交易前的最新 Oracle 索引
            slot0Start.observationIndex,
            // 當前區(qū)塊的時間
            cache.blockTimestamp,
            // 交易前的價格的 tick ,如前文所述,這樣做是為了防止攻擊
            slot0Start.tick,
            // 交易前的價格對應的流動性
            cache.liquidityStart,
            // 當前的 Oracle 數(shù)量
            slot0Start.observationCardinality,
            // 可用的 Oracle 數(shù)量
            slot0Start.observationCardinalityNext
        );
    // 更新最新 Oracle 指向的索引信息以及當前 Oracle 數(shù)據(jù)的總數(shù)目
    (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (
        state.sqrtPriceX96,
        state.tick,
        observationIndex,
        observationCardinality
    );
} else {
    ...
}

這里首先檢查價格是否發(fā)生了變化,當價格變化時,需要寫入 Oracle 數(shù)據(jù),調(diào)用的是 observations.write 函數(shù):

function write(
    Observation[65535] storage self,
    uint16 index,
    uint32 blockTimestamp,
    int24 tick,
    uint128 liquidity,
    uint16 cardinality,
    uint16 cardinalityNext
) internal returns (uint16 indexUpdated, uint16 cardinalityUpdated) {
    // 獲取當前的 Oracle 數(shù)據(jù)
    Observation memory last = self[index];

    // 如前文所述,同一個區(qū)塊內(nèi),只會在第一筆交易中寫入 Oracle 數(shù)據(jù)
    if (last.blockTimestamp == blockTimestamp) return (index, cardinality);

    // 檢查是否需要使用新的數(shù)組空間
    if (cardinalityNext > cardinality && index == (cardinality - 1)) {
        cardinalityUpdated = cardinalityNext;
    } else {
        cardinalityUpdated = cardinality;
    }

    // 本次寫入的索引,使用余數(shù)實現(xiàn) ring buffer
    indexUpdated = (index + 1) % cardinalityUpdated;
    // 寫入 Oracle 數(shù)據(jù)
    self[indexUpdated] = transform(last, blockTimestamp, tick, liquidity);
}

寫入時會計算出需要使用的索引數(shù),如果可用空間用滿會重新從頭開始寫入。Oracle 數(shù)據(jù)使用 transform 函數(shù)生成:

function transform(
    Observation memory last,
    uint32 blockTimestamp,
    int24 tick,
    uint128 liquidity
) private pure returns (Observation memory) {
    // 上次 Oracle 數(shù)據(jù)和本次的時間差
    uint32 delta = blockTimestamp - last.blockTimestamp;
    return
        Observation({
            blockTimestamp: blockTimestamp,
            // 計算 tick index 的時間加權(quán)累積值
            tickCumulative: last.tickCumulative + int56(tick) * delta,
            // 計算時間加權(quán)累積值
            liquidityCumulative: last.liquidityCumulative + uint160(liquidity) * delta,
            initialized: true
        });
}

這樣就完成了一次 Oracle 數(shù)據(jù)的寫入。

Oracle 的使用

第三方開發(fā)者要使用 Oracle, 調(diào)用交易池的 UniswapV3Pool.observe() 函數(shù)即可:

function observe(uint32[] calldata secondsAgos)
    external
    view
    override
    noDelegateCall
    returns (int56[] memory tickCumulatives, uint160[] memory liquidityCumulatives)
{
    return
        observations.observe(
            _blockTimestamp(),
            secondsAgos,
            slot0.tick,
            slot0.observationIndex,
            liquidity,
            slot0.observationCardinality
        );
}

傳入的參數(shù) secondsAgos 是一個動態(tài)數(shù)組,顧名思義表示請求 N 秒前的數(shù)據(jù),使用數(shù)組可以一次請求多個歷史數(shù)據(jù)。返回的 tickCumulatives 和 liquidityCumulatives 也是動態(tài)數(shù)組,記錄了請求參數(shù)中對應時間戳的 tick index 累積值和流動性累積值。數(shù)據(jù)的處理是在 observations.observe() 中完成的:

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 liquidityCumulatives) {
    require(cardinality > 0, 'I');

    tickCumulatives = new int56[](secondsAgos.length);
    liquidityCumulatives = new uint160[](secondsAgos.length);
    // 遍歷傳入的時間參數(shù),獲取結(jié)果
    for (uint256 i = 0; i < secondsAgos.length; i++) {
        (tickCumulatives[i], liquidityCumulatives[i]) = observeSingle(
            self,
            time,
            secondsAgos[i],
            tick,
            index,
            liquidity,
            cardinality
        );
    }
}

這個函數(shù)就是通過遍歷請求參數(shù),獲取每一個請求時間點的 Oracle 數(shù)據(jù),具體數(shù)據(jù)通過 observeSingle() 函數(shù)來獲?。?/p>

function observeSingle(
    Observation[65535] storage self,
    uint32 time,
    uint32 secondsAgo,
    int24 tick,
    uint16 index,
    uint128 liquidity,
    uint16 cardinality
) private view returns (int56 tickCumulative, uint160 liquidityCumulative) {
    // secondsAgo 為 0 表示當前的最新 Oracle 數(shù)據(jù)
    if (secondsAgo == 0) {
        Observation memory last = self[index];
        if (last.blockTimestamp != time) last = transform(last, time, tick, liquidity);
        return (last.tickCumulative, last.liquidityCumulative);
    }

    // 計算出請求的時間戳
    uint32 target = time - secondsAgo;

    // 計算出請求時間戳最近的兩個 Oracle 數(shù)據(jù)
    (Observation memory beforeOrAt, Observation memory atOrAfter) =
        getSurroundingObservations(self, time, target, tick, index, liquidity, cardinality);

    Oracle.Observation memory at;
    // 如果請求時間和返回的左側(cè)時間戳吻合,那么可以直接使用
    if (target == beforeOrAt.blockTimestamp) {
        at = beforeOrAt;
    // 如果請求時間和返回的右側(cè)時間戳吻合,那么可以直接使用
    } else if (target == atOrAfter.blockTimestamp) {
        at = atOrAfter;
    } else {
        // 當請請求的時間在中間時,計算根據(jù)增長率計算出請求的時間點的 Oracle 值并返回
        uint32 delta = atOrAfter.blockTimestamp - beforeOrAt.blockTimestamp;
        int24 tickDerived = int24((atOrAfter.tickCumulative - beforeOrAt.tickCumulative) / delta);
        uint128 liquidityDerived =
            uint128((atOrAfter.liquidityCumulative - beforeOrAt.liquidityCumulative) / delta);
        at = transform(beforeOrAt, target, tickDerived, liquidityDerived);
    }

    return (at.tickCumulative, at.liquidityCumulative);
}

在這函數(shù)中,會先調(diào)用 getSurroundingObservations() 找出的時間點前后,最近的兩個 Oracle 數(shù)據(jù)。然后通過時間差的比較計算出需要返回的數(shù)據(jù):

如果和其中的某一個的時間戳相等,那么可以直接返回
如果在兩個時間點的中間,那么通過計算增長率的方式,計算出請求時間點的 Oracle 數(shù)據(jù)并返回
getSurroundingObservations() 函數(shù)的作用是在已記錄的 Oracle 數(shù)組中,找到時間戳離其最近的兩個 Oracle 數(shù)據(jù):

function getSurroundingObservations(
    Observation[65535] storage self,
    uint32 time,
    uint32 target,
    int24 tick,
    uint16 index,
    uint128 liquidity,
    uint16 cardinality
) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) {
    // 先把 beforeOrAt 設(shè)置為當前最新數(shù)據(jù)
    beforeOrAt = self[index];

    // 檢查 beforeOrAt 是否 <= target
    if (lte(time, beforeOrAt.blockTimestamp, target)) {
        if (beforeOrAt.blockTimestamp == target) {
            // 如果時間戳相等,那么可以忽略 atOrAfter 直接返回
            return (beforeOrAt, atOrAfter);
        } else {
            // 當前區(qū)塊中發(fā)生代幣對的交易之前請求此函數(shù)時可能會發(fā)生這種情況
            // 需要將當前還未持久化的數(shù)據(jù),封裝成一個 Oracle 數(shù)據(jù)返回,
            return (beforeOrAt, transform(beforeOrAt, target, tick, liquidity));
        }
    }

    // 將 beforeOrAt 調(diào)整至 Oracle 數(shù)組中最老的數(shù)據(jù)
    // 即為當前 index 的下一個數(shù)據(jù),或者 index 為 0 的數(shù)據(jù)
    beforeOrAt = self[(index + 1) % cardinality];
    if (!beforeOrAt.initialized) beforeOrAt = self[0];

    // ensure that the target is chronologically at or after the oldest observation
    require(lte(time, beforeOrAt.blockTimestamp, target), 'OLD');

    // 然后通過二分查找的方式找到離目標時間點最近的前后兩個 Oracle 數(shù)據(jù)
    return binarySearch(self, time, target, index, cardinality);
}

這個函數(shù)會調(diào)用 binarySearch() 通過二分查找的方式,找到目標離目標時間點最近的前后兩個 Oracle 數(shù)據(jù),其中的具體實現(xiàn)這里就不再描述了。

最終,UniswapV3Pool.observe() 將會返回請求者所請求的每一個時間點的 Oracle 數(shù)據(jù),請求者可以根據(jù)這些數(shù)據(jù)計算出交易對的 TWAP(時間加權(quán)平均價,幾何平均數(shù)),計算公式在前文

同時因為 Oracle 數(shù)據(jù)中還包含了流動性的時間累積值,還可以計算出交易池在一段時間內(nèi)的 TWAL(時間加權(quán)平均流動性,算是平均數(shù))。

Oracle 數(shù)組擴容

之前說過,雖然合約定義了 Oracle 使用 65535 長度的數(shù)組,但是并不會在一開始就使用這么多的空間,這樣做是因為:

向空數(shù)組中寫入 Oracle 數(shù)據(jù)是比較昂貴的操作(SSTORE)
寫入 Oracle 數(shù)據(jù)的操作發(fā)生在交易的操作中
這些操作如果由交易者負擔,是不公平的,因為代幣交易者并不一定是 Oracle 的使用者
因此 Uniswap v3 在初始時 Oracle 數(shù)組僅可以寫入一個數(shù)據(jù),這個是通過交易池合約的 slot0.observationCardinalityNext 變量控制的。

當初始設(shè)置不滿足需求時,合約提供了單獨接口,讓對 Oracle 歷史數(shù)據(jù)有需求的開發(fā)者,自行調(diào)用接口來擴展交易池 Oracle 中存儲數(shù)據(jù)的上限。這樣就將 Oracle 數(shù)組存儲空間初始化操作的 gas 費轉(zhuǎn)移到了 Oracle 的需求方,而不是由代幣交易者承擔。

通過 increaseObservationCardinalityNext() 可以擴展交易池的 Oracle 數(shù)組可用容量,傳入的參數(shù)為期望存儲的歷史數(shù)據(jù)個數(shù)。

function increaseObservationCardinalityNext(uint16 observationCardinalityNext)
    external
    override
    lock
    noDelegateCall
{
    uint16 observationCardinalityNextOld = slot0.observationCardinalityNext; // for the event
    uint16 observationCardinalityNextNew =
        observations.grow(observationCardinalityNextOld, observationCardinalityNext);
    slot0.observationCardinalityNext = observationCardinalityNextNew;
    if (observationCardinalityNextOld != observationCardinalityNextNew)
        emit IncreaseObservationCardinalityNext(observationCardinalityNextOld, observationCardinalityNextNew);
}

這個函數(shù)調(diào)用了 observations.grow() 完成底層存儲空間的初始化:

function grow(
    Observation[65535] storage self,
    uint16 current,
    uint16 next
) internal returns (uint16) {
    require(current > 0, 'I');
    // no-op if the passed next value isn't greater than the current next value
    if (next <= current) return current;
    // 對數(shù)組中將來可能會用到的槽位進行寫入,以初始化其空間,避免在 swap 中初始化
    for (uint16 i = current; i < next; i++) self[i].blockTimestamp = 1;
    return next;
}

這里可以看到,通過循環(huán)的方式,將 Oracle 數(shù)組中未來可能被使用的空間中寫入數(shù)據(jù)。這樣做的目的是將數(shù)據(jù)進行初始化,這樣在代幣交易寫入新的 Oracle 數(shù)據(jù)時,不需要再進行初始化,可以讓交易時更新 Oracle 不至于花費太多的 gas,SSTORE 指令由 20000 降至 5000??梢詤⒖迹?a target="_blank">EIP-1087, EIP-2200, EIP-2929,具體實現(xiàn):core/vm/gas_table.go。

Oracle 數(shù)據(jù)容量

當 Oracle 數(shù)據(jù)可使用空間被擴容至最大,即 65535 時,假設(shè)平均出塊時間為 13 秒,那么此時至少可以存儲最近 9.8 天的歷史數(shù)據(jù)。

至此,關(guān)于 Uniswap v3 Oracle 的所有操作就介紹完畢了。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容