使用“扫一扫”即可将网页分享至朋友圈。
价格预言机的使用总结(二):UniswapV2篇
前言
该系列的前一篇文章介绍了 Chainlink 价格预言机的使用,其目前也被大部分 DeFi 应用所使用,但依然存在局限性。首先是所支持的 Token 的覆盖率还不全,尤其是长尾资产,大多还未支持,比如 SHIB,目前只在 BSC 主网有 SHIB/USD 的 Price Feed,而其它网络的都还没有,连 Ethereum 的都还没支持。其次,有些资产的偏差阈值较大,价格更新也比较慢,可能长达十几二十个小时才会更新价格,比如 BNT。
这时候就需要考虑其它价格预言机了,而 UniswapV2 和 UniswapV3 都是不错的选择。
本篇先来聊聊如何使用 UniswapV2 作为价格预言机。
UniswapV2 价格预言机
UniswapV2 使用的价格预言机称为 TWAP(Time-Weighted Average Price),即时间加权平均价格。不同于链下聚合的 Chainlink 取自多个不同交易所的数据作为数据源,TWAP 的数据源来自于 Uniswap 自身的交易数据,价格的计算也都是在链上执行的,因此,TWAP 属于链上预言机。
TWAP 的原理比较简单,首先,在 UniswapV2Pair 合约中,会存储两个变量 price0CumulativeLast 和 price1CumulativeLast,在 _update() 函数中会更新这两个变量,其相关代码如下:
contract UniswapV2Pair { ... uint32 private blockTimestampLast; uint public price0CumulativeLast; uint public price1CumulativeLast; ... // update reserves and, on the first call per block, price accumulators function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private { ... uint32 blockTimestamp = uint32(block.timestamp % 2**32); uint32 timeElapsed = blockTimestamp - blockTimestampLast; if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { // * never overflows, and + overflow is desired price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; } blockTimestampLast = blockTimestamp; ... } }price0CumulativeLast 和 price1CumulativeLast 分别记录了 token0 和 token1 的累计价格。所谓累计价格,其代表的是整个合约历史中每一秒的 Uniswap 价格总和。且只会在每个区块第一笔交易时执行累加计算,累加的值不是当前区块的第一笔交易的价格,而是在这之前的最后一笔交易的价格,所以至少也是上个区块的价格。取自之前区块的价格,可以大大提高操控价格的成本,所以自然也提高了安全性。
如上图所示,合约的第一个区块为 Block 122,这时候,价格和时间差都为 0,所以累计价格也为 0。到了下一个区块 Block 123,这时候取自上个区块的最后一口价格 10.2,且经过的时间差为 7,因此就可以计算出累计价格 priceCumulative = 10.2 * 7 = 71.4。再到下个区块 Block 124,取自上一口价格 10.3,两个区块间的时间差为 8,那此时的累计价格就变成了 71.4 + (10.3 * 8) = 153.8。Block 125 的时候也同理,上口价格为 10.5,区块时间差为 5,所以最新的累计价格就变成了 153.8 + (10.5 * 5) = 206.3。
有了这个基础之后,就可以计算 TWAP 了。
固定时间窗口 TWAP
计算 TWAP 的原理也是非常简单,如上图所示,这是计算时间间隔为 1 小时的 TWAP,取自开始和结束时的累计价格和两区块当时的时间戳,两者的累计价格相减,再除以两者之间的时间差,就算出这 1 小时内的 TWAP 价格了。
这是 TWAP 最简单的计算方式,也称为固定时间窗口的 TWAP。下面来讲讲具体如何实现。
Uniswap 官方也有提供了一个示例代码来计算固定时间窗口的 TWAP,其代码放在 v2-periphery 项目中:
https://github.com/Uniswap/v2-periphery/blob/master/contracts/examples/ExampleOracleSimple.sol
该示例代码也比较简单,我们直接贴上代码看看:
pragma solidity =0.6.6; import '@uniswap/v2-core/contracts/interfaces/IUniswapV2Factory.sol'; import '@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol'; import '@uniswap/lib/contracts/libraries/FixedPoint.sol'; import '../libraries/UniswapV2OracleLibrary.sol'; import '../libraries/UniswapV2Library.sol'; // fixed window oracle that recomputes the average price for the entire period once every period // note that the price average is only guaranteed to be over at least 1 period, but may be over a longer period contract ExampleOracleSimple { using FixedPoint for *; uint public constant PERIOD = 24 hours; IUniswapV2Pair immutable pair; address public immutable token0; address public immutable token1; uint public price0CumulativeLast; uint public price1CumulativeLast; uint32 public blockTimestampLast; FixedPoint.uq112x112 public price0Average; FixedPoint.uq112x112 public price1Average; constructor(address factory, address tokenA, address tokenB) public { IUniswapV2Pair _pair = IUniswapV2Pair(UniswapV2Library.pairFor(factory, tokenA, tokenB)); pair = _pair; token0 = _pair.token0(); token1 = _pair.token1(); price0CumulativeLast = _pair.price0CumulativeLast(); // fetch the current accumulated price value (1 / 0) price1CumulativeLast = _pair.price1CumulativeLast(); // fetch the current accumulated price value (0 / 1) uint112 reserve0; uint112 reserve1; (reserve0, reserve1, blockTimestampLast) = _pair.getReserves(); require(reserve0 != 0 && reserve1 != 0, 'ExampleOracleSimple: NO_RESERVES'); // ensure that there's liquidity in the pair } function update() external { (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) = UniswapV2OracleLibrary.currentCumulativePrices(address(pair)); uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired // ensure that at least one full period has passed since the last update require(timeElapsed >= PERIOD, 'ExampleOracleSimple: PERIOD_NOT_ELAPSED'); // overflow is desired, casting never truncates // cumulative price is in (uq112x112 price * seconds) units so we simply wrap it after division by time elapsed price0Average = FixedPoint.uq112x112(uint224((price0Cumulative - price0CumulativeLast) / timeElapsed)); price1Average = FixedPoint.uq112x112(uint224((price1Cumulative - price1CumulativeLast) / timeElapsed)); price0CumulativeLast = price0Cumulative; price1CumulativeLast = price1Cumulative; blockTimestampLast = blockTimestamp; } // note this will always return 0 before update has been called successfully for the first time. function consult(address token, uint amountIn) external view returns (uint amountOut) { if (token == token0) { amountOut = price0Average.mul(amountIn).decode144(); } else { require(token == token1, 'ExampleOracleSimple: INVALID_TOKEN'); amountOut = price1Average.mul(amountIn).decode144(); } } }PERIOD 指定为了 24 小时,说明这个示例计算 TWAP 的固定时间窗口为 24 小时,即每隔 24 小时才更新一次价格。
该示例也只保存一个交易对的价格,即 token0-token1 的价格。price0Average 和 price1Average 分别就是 token0 和 token1 的 TWAP 价格。比如,token0 为 WETH,token1 为 USDC,那 price0Average 就是 WETH 对 USDC 的价格,而 price1Average 则是 USDC 对 WETH 的价格。
update() 函数就是更新 TWAP 价格的函数,这一般需要链下程序的定时任务来触发,按照这个示例的话,就是链下的定时任务需要每隔 24 小时就定时触发调用 update() 函数。
update() 函数的实现逻辑也和上面所述的公式一致:
读取出当前最新的累计价格和当前的时间戳;
计算出当前时间和上一次更新价格时的时间差 timeElapsed,要求该时间差需要达 24 小时;
根据公式 TWAP = (priceCumulative - priceCumulativeLast) / timeElapsed 计算得到最新的 TWAP,即 priceAverage;
更新 priceCumulativeLast 和 blockTimestampLast 为当前最新的累计价格和时间戳。
不过,有一点需要注意,因为 priceCumulative 本身计算存储时是做了左移 112 位的操作的,所以计算所得的 priceAverage 也是左移了 112 位的。
consult() 函数则可查询出用 TWAP 价格计算可兑换的数量。比如,token0 为 WETH,token1 为 USDC,假设 WETH 的价格为 3000 USDC,查询 consult() 时,若传入的参数 token 为 token0 的地址,amountIn 为 2,那输出的 amountOut 则为 3000 * 2 = 6000,可理解为若支付 2 WETH,就可根据价格换算成 6000 USDC。
滑动时间窗口 TWAP
固定时间窗口 TWAP 的原理和实现,比较简单,但其最大的不足就是价格变化不够平滑,时间窗口越长,价格变化就可能会越陡峭。因此,在实际应用中,更多其实是用滑动时间窗口的 TWAP。
所谓滑动时间窗口 TWAP,就是说,计算 TWAP 的时间窗口并非固定的,而是滑动的。这种算法的主要原理就是将时间窗口划分为多个时间片段,每过一个时间片段,时间窗口就会往右滑动一格,如下图所示:
上图所示的时间窗口为 1 小时,划分为了 6 个时间片段,每个时间片段则为 10 分钟。那每过 10 分钟,整个时间窗口就会往右滑动一格。而计算 TWAP 时的公式则没有变,依然还是取自时间窗口的起点和终点。如果时间窗口为 24 小时,按照固定时间窗口算法,每隔 24 小时 TWAP 价格才会更新,但使用滑动时间窗口算法后,假设时间片段为 1 小时,则 TWAP 价格是每隔 1 小时就会更新。
Uniswap 官方也同样提供了这种滑动时间窗口 TWAP 实现的示例代码,其 Github 地址为:
https://github.com/Uniswap/v2-periphery/blob/master/contracts/examples/ExampleSlidingWindowOracle.sol
我们也贴上代码看看:
pragma solidity =0.6.6; import '@uniswap/v2-core/contracts/interfaces/IUniswapV2Factory.sol'; import '@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol'; import '@uniswap/lib/contracts/libraries/FixedPoint.sol'; import '../libraries/SafeMath.sol'; import '../libraries/UniswapV2Library.sol'; import '../libraries/UniswapV2OracleLibrary.sol'; // sliding window oracle that uses observations collected over a window to provide moving price averages in the past // `windowSize` with a precision of `windowSize / granularity` // note this is a singleton oracle and only needs to be deployed once per desired parameters, which // differs from the simple oracle which must be deployed once per pair. contract ExampleSlidingWindowOracle { using FixedPoint for *; using SafeMath for uint; struct Observation { uint timestamp; uint price0Cumulative; uint price1Cumulative; } address public immutable factory; // the desired amount of time over which the moving average should be computed, e.g. 24 hours uint public immutable windowSize; // the number of observations stored for each pair, i.e. how many price observations are stored for the window. // as granularity increases from 1, more frequent updates are needed, but moving averages become more precise. // averages are computed over intervals with sizes in the range: // [windowSize - (windowSize / granularity) * 2, windowSize] // e.g. if the window size is 24 hours, and the granularity is 24, the oracle will return the average price for // the period: // [now - [22 hours, 24 hours], now] uint8 public immutable granularity; // this is redundant with granularity and windowSize, but stored for gas savings & informational purposes. uint public immutable periodSize; // mapping from pair address to a list of price observations of that pair mapping(address => Observation[]) public pairObservations; constructor(address factory_, uint windowSize_, uint8 granularity_) public { require(granularity_ > 1, 'SlidingWindowOracle: GRANULARITY'); require( (periodSize = windowSize_ / granularity_) * granularity_ == windowSize_, 'SlidingWindowOracle: WINDOW_NOT_EVENLY_DIVISIBLE' ); factory = factory_; windowSize = windowSize_; granularity = granularity_; } // returns the index of the observation corresponding to the given timestamp function observationIndexOf(uint timestamp) public view returns (uint8 index) { uint epochPeriod = timestamp / periodSize; return uint8(epochPeriod % granularity); } // returns the observation from the oldest epoch (at the beginning of the window) relative to the current time function getFirstObservationInWindow(address pair) private view returns (Observation storage firstObservation) { uint8 observationIndex = observationIndexOf(block.timestamp); // no overflow issue. if observationIndex + 1 overflows, result is still zero. uint8 firstObservationIndex = (observationIndex + 1) % granularity; firstObservation = pairObservations[pair][firstObservationIndex]; } // update the cumulative price for the observation at the current timestamp. each observation is updated at most // once per epoch period. function update(address tokenA, address tokenB) external { address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB); // populate the array with empty observations (first call only) for (uint i = pairObservations[pair].length; i < granularity; i++) { pairObservations[pair].push(); } // get the observation for the current period uint8 observationIndex = observationIndexOf(block.timestamp); Observation storage observation = pairObservations[pair][observationIndex]; // we only want to commit updates once per period (i.e. windowSize / granularity) uint timeElapsed = block.timestamp - observation.timestamp; if (timeElapsed > periodSize) { (uint price0Cumulative, uint price1Cumulative,) = UniswapV2OracleLibrary.currentCumulativePrices(pair); observation.timestamp = block.timestamp; observation.price0Cumulative = price0Cumulative; observation.price1Cumulative = price1Cumulative; } } // given the cumulative prices of the start and end of a period, and the length of the period, compute the average // price in terms of how much amount out is received for the amount in function computeAmountOut( uint priceCumulativeStart, uint priceCumulativeEnd, uint timeElapsed, uint amountIn ) private pure returns (uint amountOut) { // overflow is desired. FixedPoint.uq112x112 memory priceAverage = FixedPoint.uq112x112( uint224((priceCumulativeEnd - priceCumulativeStart) / timeElapsed) ); amountOut = priceAverage.mul(amountIn).decode144(); } // returns the amount out corresponding to the amount in for a given token using the moving average over the time // range [now - [windowSize, windowSize - periodSize * 2], now] // update must have been called for the bucket corresponding to timestamp `now - windowSize` function consult(address tokenIn, uint amountIn, address tokenOut) external view returns (uint amountOut) { address pair = UniswapV2Library.pairFor(factory, tokenIn, tokenOut); Observation storage firstObservation = getFirstObservationInWindow(pair); uint timeElapsed = block.timestamp - firstObservation.timestamp; require(timeElapsed <= windowSize, 'SlidingWindowOracle: MISSING_HISTORICAL_OBSERVATION'); // should never happen. require(timeElapsed >= windowSize - periodSize * 2, 'SlidingWindowOracle: UNEXPECTED_TIME_ELAPSED'); (uint price0Cumulative, uint price1Cumulative,) = UniswapV2OracleLibrary.currentCumulativePrices(pair); (address token0,) = UniswapV2Library.sortTokens(tokenIn, tokenOut); if (token0 == tokenIn) { return computeAmountOut(firstObservation.price0Cumulative, price0Cumulative, timeElapsed, amountIn); } else { return computeAmountOut(firstObservation.price1Cumulative, price1Cumulative, timeElapsed, amountIn); } } }要实现滑动时间窗口算法,就需要将时间分段,还需要保存每个时间段的 priceCumulative。在这实现的示例代码中,定义了结构体 Observation,用来保存每个时间片段的数据,包括两个 token 的 priceCumulative 和记录的时间点 timestamp。还定义了 pairObservations 用来存储每个 pair 的 Observation 数组,而数组实际的长度取决于将整个时间窗口划分为多少个时间片段。
windowSize 表示时间窗口大小,比如 24 小时,granularity 是划分的时间片段数量,比如 24 段,periodSize 则是每时间片段的大小,比如 1 小时,是由 windowSize / granularity 计算所得。这几个值都在构造函数中进行了初始化。
触发 update() 函数则更新存储最新时间片段的 observation,如时间片段大小为 1 小时,即每隔 1 小时就要触发 update() 函数一次。因为这个示例中是支持多个 pair 的,所以 update() 时需要指定所要更新的两个 token。
而查询当前 TWAP 价格的计算就在 consult() 函数里实现了。首先,先获取到当前时间窗口里的第一个时间片段的 observation,也算出当前时间与第一个 observation 时间的时间差,且读取出当前最新的 priceCumulative,之后就在 computeAmountOut() 函数里计算得到最新的 TWAP 价格 priceAverage,且根据 amountIn 算出了 amountOut 并返回。
总结
本文我们主要介绍了被广泛使用的一种链上预言机 TWAP(时间加权平均价格),且介绍了固定时间窗口和滑点时间窗口两种算法的 TWAP。虽然,TWAP 是由 Uniswap 推出的,但因为很多其他 DEX 也采用了和 Uniswap 一样的底层实现,如 SushiSwap、PancakeSwap 等,所以这些 DEX 也可以用同样的算法计算出对应的 TWAP。
但使用 UniswapV2 的 TWAP,其主要缺陷就是需要链下程序定时触发 update() 函数,存在维护成本。UniswapV3 的 TWAP 则解决了这个问题,下一篇会来聊聊其具体是如何实现的。
文章首发于「Keegan小钢」公众号:
https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494441&idx=1&sn=57a97690390b93770c5a906dce4157c8&chksm=8b685079bc1fd96f9ab60cc1b41b8642abf807a13a37c12f05a280be2e03f3a9288a047b5739&token=1584634265&lang=zh_CN#rd
“广东财经网”的新闻页面文章、图片、音频、视频等稿件均为自媒体人、第三方机构发布或转载。如稿件涉及版权等问题,请与
我们联系删除或处理,客服邮箱1098101642@qq.com,稿件内容仅为传递更多信息之目的,不代表本网观点,亦不代表本网站赞同
其观点或证实其内容的真实性。
- 声音提醒
- 60秒后自动更新
【民政部回应何时恢复婚姻登记】民政部社会事务司二级巡视员杨宗涛表示,婚姻登记场所是人群聚集场所,部分地区暂停婚姻登记工作是对人民群众安全负责。未停止登记的地方推广用电话、网络、qq群预约登记,控制登记人数,分批分段登记,减少人员聚集和在登记机关停留时间。已停止婚姻登记地方将根据当地疫情控制情况逐渐恢复。
15:54欧洲央行副行长金多斯:欧洲央行还没有达到逆转利率。宽松政策的副作用更明显了。
15:54财经网站Forexlive分析师Justin Low评瑞士1月CPI月率:尽管通胀年率保持稳定,但核心通胀率有所下降,这一点令人更加担忧。这只是进一步巩固了瑞士央行维持现有货币政策不变,并可能在未来寻求更多宽松措施的观点。
15:54【武汉两大批发市场商户开业率超过80%】商务部市场建设司司长朱小良10日称,目前武汉生活必需品供应基本正常,除冷鲜肉、鲜叶菜等少数品种存在结构性短缺外,大部分重要生活物资供应充足,未发生明显抢购或脱销断档情况。当地白沙洲和四季美两大批发市场正常营业,商户开业率超过80%,日交易量上升至3000吨以上。(国是直通车)
15:53【日本2019年经常项目顺差增长4.4%】日本财务省10日发布的国际收支初步统计报告显示,主要受外国人入境游消费扩大影响,2019年日本经常项目顺差增长4.4%。报告显示,2019年日本经常项目顺差为20.06万亿日元(1美元约合109.8日元)。其中,商品贸易方面,由于液化天然气价格下跌等因素,进口下降5.6%至75.56万亿日元;由于汽车部件及钢铁等产品出口减少,出口下降6.3%至76.12万亿日元。货物贸易顺差减少53.8%,为5536亿日元。(新华社)
15:53欧元兑美元EUR/USD短线波动不大,现报1.0952。
15:53瑞士1月CPI年率:0.2%,前值:0.2%,预期:0.1%;瑞士1月CPI月率:-0.2%,前值:0%,预期:-0.2%。
15:52英镑兑美元GBP/USD短线走低13点,现报1.2890。
15:52【宁德时代“供电”国产特斯拉 有望拉低售价20%】全国乘联会秘书长崔东树分析认为,“随着特斯拉国产化率提升和产能爬坡,未来特斯拉国产车型的价格下探空间还是很大的。”他预计今年下半年,国产Model 3车型的售价就有望降至25万元,降幅接近20%。(新京报)
15:51【内蒙古:对不裁员或少裁员企业返还上年度50%失业保险费】据内蒙古新型冠状病毒肺炎疫情防控工作指挥部消息,疫情期间,内蒙古对不裁员或少裁员的企业返还上年度实际缴纳失业保险费的50%,对面临暂时性经营困难的中小企业,返还标准提高到上年度6个月企业及其职工缴纳社会保险费的50%。
15:51【商务部:2019年服务进出口总额54152.9亿元 同比增长2.8%】商务部新闻发言人表示,2019年,在服务贸易创新发展试点等政策的激励下,我国服务贸易总体保持平稳向上态势,逆差明显下降,结构显著优化,高质量发展成效初步显现。全年服务进出口总额54152.9亿元(人民币,下同),同比增长2.8%。其中,出口总额19564.0亿元,同比增长8.9%;进口总额34588.9亿元,同比减少0.4%。(第一财经)
15:51【振华股份:疫情导致下游客户开工推迟 产品库存上升】振华化学公告,公司目前生产经营稳定,所有产能均正常开工,原材料采购能基本满足生产需要,由于疫情导致下游客户开工推迟,公司出货量减少,产品库存有所上升。公司将根据客户需求、疫情及市场变化,适度调整生产经营策略,尽可能保持生产经营的稳定。
15:50环旭电子2月10日晚间公告,公司2020年1月合并营业收入为23.27亿元,较去年同期的合并营业收入减少27.83%,较2019年12月合并营业收入环比减少37.23%。
15:49精测电子:与京东方集团签订了多份销售合同,合同累计金额达到6.96亿元。
17:12华夏银行:银保监会同意本公司在全国银行间债券市场发行不超过100亿元人民币的金融债券,募集资金全部用于绿色信贷。
17:03