如何把Uniswap v2作为预言机使用

本文探索如何把 Uniswap v2 作为预言机使用,Uniswap v2 作为预言机的原理是怎样的,如何整合。

Uniswap 是目前最流行的去中心化交易所,估计大家读已经了解它, 但我还是先把基础知识再过一遍。

什么是 Uniswap

如果你还不熟悉Uniswap[4],它是以太坊上自动提供流动性的完全去中心化协议。比较容易理解的描述是,这是一个去中心化的交易所(DEX),依靠外部的流动性提供者,将代币添加到智能合约池中,用户使用流动性中的代币直接交易。

由于 Uniswap 是在以太坊上运行,交易的是以太坊 ERC-20 代币。每个代币都有自己的智能合约和流动池。Uniswap 是完全去中心化的,因为任何代币都可以添加添加进流动池。如果还没有对一个的代币流动池存在,任何人都可以使用 UniswapFactory 创建一个,任何人都可以为一个流动池提供流动性。每笔交易向这些流动性提供者支付 0.3%的费用作为激励。

代币的价格由池中的流动性决定。比如用户用TOKEN2购买TOKEN1,那么池子里TOKEN1的供应量就会减少,而TOKEN2的供应量就会增加,TOKEN1的价格就会上涨。同样,如果用户在卖TOKEN1TOKEN1的价格也会下降。因此,代币价格总是反映了供求关系。

当然用户不一定是人,可以是一个智能合约。这使得我们可以将 Uniswap 添加到自己的合约中,为我们合约的用户增加额外的支付选项。Uniswap 让这个过程变得非常方便,下文会介绍如何整合 Uniswap。

可以直接将 Uniswap 整合到你的合约中进行交易。例如用户可以用 ETH 支付,在你的合约自动交易为 DAI,而不是一定得接收 DAI。

Uniswap 预言机

现在让我们来看看 Uniswap 如何作为预言机使用。例如,你可能想获得 DAI 的喂价,以便知晓给定 ERC-20 代币的大概的美元价格。这可以用 Uniswap 来完成,但你需要注意一些事情。

Uniswap v1 的问题

首先,只从 Uniswap 流动池中提取最后的交易价格,会有什么问题呢?

虽然这听起来可能是一个可行的策略,实际上确实有项目直接使用这个价格,但它很容易被操纵的,自然而然就会有这样的黑客事件发生[5]。那么如何操纵最后的交易价格呢?

简单,你只要在 Uniswap 上交易就可以了。上面提到过“如果用户在卖 TOKEN1,TOKEN1 的价格就会下降”。最重要的是这根本就不需要花多少资金去做。你只需要卖出 TOKEN1 兑换 TOKEN2,此时使用操纵的价格进行操作,之后立刻卖回 TOKEN2。例如像闪电贷[6]中那样,攻击的资金成本几乎 0(手续费除外)。

一般来说,如果你想了解更多的信息,可以看看这篇很赞的文章价格预言机不总是可靠[7],讲述了预言机和价格操纵。

Uniswap v2: 时间加权平均价格

首先 Uniswap v2 只在一个区块结束时测算价格。就是说要想操纵价格,就必须购买代币,等待下一个区块,然后才能够再卖回去。这使得其他交易者有更多的套利机会,从而增加了价格操纵者的风险/成本。

其次,在 Uniswap v2 中增加了时间加权平均价格功能。在我们把想象得太复杂之前,其实基本功能非常简单:

每个流动池都添加两个新方法:

  • price0CumulativeLast()
  • price1CumulativeLast()

光靠这两个方法是不够的,毕竟我们感兴趣的是一段时间内的平均价格,所以我们还缺少了 priceCumulativeLast的历史价格。

例如,要获取 token0 在 24 小时内的时间加权平均价格,我们需要:

  1. 存储price0CumulativeLast()和此时对应时间戳(block.timestamp)

  2. 等待 24 小时

  3. 计算 24 小时平均价格为(price0CumulativeLast()-price0CumulativeOld)/(block.timestamp-timestampOld)

在某些情况下,只有price0可能已经足够好了。然而,使用 token0 或 token1 的时间加权平均值实际上会产生不同的结果。所以 Uniswap 干脆同时提供了这两个加权值。

把 Uniswap 作为预言机集成进合约

棘手的是历史数据, 这意味着我们不能只把它整合到你的合约中。根据要求和实施的复杂性,可以选择简单、中等或复杂的预言机集成方式。

1. 简单方法:手动固定时间窗口

在手动设置中,你自己定期调用 update函数。例如,对于 24 小时加权平均,这个函数需要每天调用一次, 平均价格按上述公式计算。

  • 价格加权差值/时间推移

FixedPoint.uq112x112在概念上并不重要。它只是将结果表示为一个定点数字,数字两边有 112 位。

function update() external {
    (uint price0Cumulative, , uint32 blockTimestamp) = UniswapV2OracleLibrary.currentCumulativePrices(pairAddress);
    uint32 timeElapsed = blockTimestamp - blockTimestampLast;

    require(timeElapsed >= TIME_PERIOD, 'UniOracle: Time period not yet elapsed');

    price0Average = FixedPoint.uq112x112(uint224((price0Cumulative - price0CumulativeLast) / timeElapsed));
    price0CumulativeLast = price0Cumulative;
    blockTimestampLast = blockTimestamp;
}

现在我们已经有了平均价格,以 token0 为单位的 amountIn可以计算出以 token1 为单位的 amountOut

function convertToken0UsingTimeWeightedPrice(uint amountIn) external view returns (uint amountOut) {
       return price0Average.mul(amountIn).decode144();
}

我们没有计算price1Average。如果你想非常精确,你可能想用price1CumulativeLast来计算。除此之外你还可以直接取price0Average的倒数:

function convertToken1UsingTimeWeightedPrice(uint amountIn) external view returns (uint amountOut) {
       uint256 price1Average = price0Average.reciprocal();
       return price1Average.mul(amountIn).decode144();
}

这种方法的缺点明显,就是你必须手动定时调用合约。此外,这个固定窗口的平均价格对最近的价格变化反应较慢,以及对历史价格的权重与对最近价格的权重相同(而越近的价格权重更大才更好)。

2. 中等方法:手动移动窗口

通过移动窗口方法,可以定义窗口的大小。然后,你可以指定一个粒度,它表示在这个窗口内应该有多少个测量点。例如给定值:

  • 窗口大小:2 个月
  • 颗粒度:3

会是这样的。

图片
移动平均线

计算当前窗口的平均值。你的粒度越高,平均值就越精确,但也需要调用update()的次数就越多。

完整的 Remix 实例:你可以在 Remix 中看到一个完整的例子这里[8]。本例经过修改后,可以在 Kovan 测试网络上使用 DAI 和 WETH 对。在尝试读取结果之前,一定要先调用 update,并考虑颗粒度和时间窗口。

如果你缺少了这一步,调用将出现SlidingWindowOracle: MISSING_HISTORICAL_OBSERVATION 失败. 而且由于 Remix 并没有显示回退和 view 函数的任何细节,你只会看到一个普通的 reverted。最简单的测试方法是用最低的粒度(=2)和合理的窗口大小(比如 30 秒)。

3. 复杂方法:自动移动窗口

最后还有一个很酷的项目,它实现了一个解决方案,不需要任何自动的update()调用。

这是如何运作的呢?

记住,我们需要price0CumulativeLast()的历史值。而这个历史值已经不在链上了, 所以没有办法直接再从合约存储中读取。但是与这个值相关的一些东西在链上…

至少对于最后 256 个区块,我们仍然可以从 EVM 中读取区块哈希值。

blockhash(uint blockNumber) returns (bytes32)

而现在我们有一个小技巧可以做。由此产生的区块哈希是默克尔树(Merkle)的根。让我试着给你一个高层次的概念,告诉你这是如何运作的。

这是一棵默克尔树:

图片
Merkle Tree

在默克尔树的根部是根哈希。这就是我们使用blockhash(uint blockNumber)可以得到的结果。它是通过对每个数据块进行哈希处理并将其作为叶子节点存储而创建的。两个叶子哈希通过哈希组合在一起,形成性的哈希再次组合,直到创建为一棵只有一个根哈希的树。

默克尔证明就是向别人证明 L3 确实包含一个给定的值。完成这个证明只需要提供 Hash 0、Hash 1-1 和 L3 块本身即可。为了证明,可以先计算 L3 的哈希值,再计算 hash1,最后计算根哈希。然后我们可以将根哈希与我们已知的根哈希进行比较。关于默克尔证明的直观解释,这里有一个很棒的解释[9]

在以太坊中,有一棵默克尔树就是状态树,它包含了所有的状态如余额,但也包含了合约存储。这意味着它也包含我们的price0CumulativeLast值。所以我们为历史价格值,制作出上述的默克尔证明。EIP-1186[10]引入了eth_getProofRPC调用,它可以从运行中的以太坊节点自动获得所需的证明数据。我们可以把证明数据传给 oracle 合约,在智能合约里面验证证明。

查看这个代码库[11]了解完整的细节,但要注意这是未经审核的代码。

未来的改进

请记住,自动移动窗口的实现只适用于最后 256 个区块(约 1 小时),因为只有这些区块哈希可以从合约中访问。然而,随着 Vitalik 提出的EIP-2935[12],这一点可能会在未来有所改变。在 EIP-2935 中,计划有一个单独的合约,保证有所有历史区块哈希。这样一来,拥有默克尔证明的 Uniswap 预言机就会变得非常强大。

该内容来自于互联网公开内容,非区块链原创内容,如若转载,请注明出处:https://htzkw.com/archives/29376

联系我们

aliyinhang@gmail.com