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

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

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

什么是Uniswap?

如果你还不熟悉 Uniswap ,它是以太坊上自动提供流动性的完全去中心化协议。 比较容易理解的描述是,这是一个去中心化的交易所(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 流动池中提取最后的交易价格,会有什么问题呢?

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

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

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

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中看到一个完整的例子 这里 。 本例经过修改后,可以在Kovan测试网络上使用DAI和WETH对。 在尝试读取结果之前,一定要先调用update,并考虑颗粒度和时间窗口。

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

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

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

这是如何运作的呢?

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

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

blockhash(uint blockNumber) returns (bytes32)

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

这是一棵默克尔树:

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

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

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

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

未来的改进

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

本翻译由 Cell Network 赞助支持。