Using the new Uniswap v2 as oracle in your contracts
How does the Uniswap v2 oracle function and how to integrate with it
We've covered Uniswap previously here. But let's go through the basics first again.
What is UniSwap?
If you're not familiar with Uniswap yet, it's a fully decentralized protocol for automated liquidity provision on Ethereum. An easier-to-understand description would be that it's a decentralized exchange (DEX) relying on external liquidity providers that can add tokens to smart contract pools and users can trade those directly.
Since it's running on Ethereum, what we can trade are Ethereum ERC-20 tokens. For each token there is its own smart contract and liquidity pool. Uniswap - being fully decentralized - has no restrictions to which tokens can be added. If no contracts for a token pair exist yet, anyone can create one using their factory and anyone can provide liquidity to a pool. A fee of 0.3% for each trade is given to those liquidity providers as incentive.
The price of a token is determined by the liquidity in a pool. For example if a user is buying TOKEN1 with TOKEN2, the supply of TOKEN1 in the pool will decrease while the supply of TOKEN2 will increase and the price of TOKEN1 will increase. Likewise, if a user is selling TOKEN1, the price of TOKEN1 will decrease. Therefore the token price always reflects the supply and demand.
And of course a user doesn't have to be a person, it can be a smart contract. That allows us to add Uniswap to our own contracts for adding additional payment options for users of our contracts. Uniswap makes this process very convenient, see below for how to integrate it.
You can integrate Uniswap directly for trading with your contracts. For example users could pay in ETH, but your contract will trade it automatically and instead receive DAI. To learn how to do this, see my tutorial here.
The Uniswap Oracle
Now let's take a look on how Uniswap can be used as an oracle. You might want to get the price feed for DAI for example, so roughly the USD price for a given ERC-20 token. This can be done with Uniswap, but you need to be aware of a few things here.
The Problem with Uniswap v1
First of all what is the issue with just taking the last traded price from a Uniswap Pool?
While this might sounds like a viable strategy first, and projects have actually used this this price feed directly, it's easy to manipulate and naturally hacks like this have occurred. So how do you manipulate the last traded price?
Easy, you just trade on Uniswap. Remember from above 'if a user is selling TOKEN1, the price of TOKEN1 will decrease'. Most importantly this doesn't even cost much to do. You just need to time it properly. Sell TOKEN1 for TOKEN2, use the manipulated price feed somewhere and immediately sell back TOKEN2 afterwards. Take Flash Loans and the capital requirements for the attack go towards 0.
Generally take a look at this great post about oracles and price manipulations if you want to learn more about it.
Uniswap v2: Time-weighted average prices
First of all Uniswap v2 measures the price only at the end of a block. Meaning to manipulate the price, one has to buy a token, wait for the next block and only then is able to sell it back again. This allows for greater arbitrage opportunities by other actors and thus an increased risk/cost for the price manipulator.
Secondly, in Uniswap v2 a time-weighted average price functionality was added. Before we make it too complicated, the basic functionality is very simple.
Each pool has two new functions:
price0CumulativeLast()
price1CumulativeLast()
Those alone won't help you. After all we are interested in an average price over time. So we're missing the historic value of priceCumulativeLast
.
For example to get the time-weighted average price for token0 over a period of 24 hours:
- store
price0CumulativeLast()
and the respective timestamp at this time (block.timestamp
) - wait 24 hours
- compute the 24h-average price as
(price0CumulativeLast() - price0CumulativeOld) / (block.timestamp - timestampOld)
Having only price0 might be good enough in some cases. However, the time-weighted average of using either token0 or token1 can actually produce different results. That's why Uniswap simply offers both.
Using Uniswap as oracle in your contracts
Now the tricky part is the historic value. It means you can't just integrate it in your contracts. Depending on your requirements and the complexity of the implementation, you can choose between simple, medium or complex oracle integration:
1. The simple method: Fixed Window Manual
In the manual setup you would call an update
function regularly yourself. For example for our 24h weighted average, this function needs to be called once per day. The average price is calculated according the the formula above:
priceDifference / timeElapsed
The FixedPoint.uq112x112
part is not important conceptually. It just represents the results as a fixed point number with 112 bits on either side of the number.
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;
}
Now that we have the average price, one can calculate the amountOut
measured in token1, for a given amountIn
measured in token0:
function convertToken0UsingTimeWeightedPrice(uint amountIn) external view returns (uint amountOut) {
return price0Average.mul(amountIn).decode144();
}
We didn't calculate the price1Average
. If you want to be very precise, you might want to calculate it using price1CumulativeLast
. Otherwise you can just take the reciprocal of the price0Average
:
function convertToken1UsingTimeWeightedPrice(uint amountIn) external view returns (uint amountOut) {
uint256 price1Average = price0Average.reciprocal();
return price1Average.mul(amountIn).decode144();
}
The obvious downside of this method is that you have to manually call the contract continuously. Further this fixed window average price reacts slowly to recent changes and places the same weight for historical prices as for more recent ones.
2. The medium method: Moving Window Manual
- window size: 2 months
- granularity: 3
would look like this:
The average is computed for the current window. The higher your granularity, the more precise the average will be, but also the more times you will need to call update()
.
Full Remix Example: You can see a fully working example in Remix here. The example is modified to work with the DAI and WETH pair on the Kovan test network. Make sure to call update and consider the granularity and time period before trying to read the result.
If you're missing that, the consult call will fail with SlidingWindowOracle: MISSING_HISTORICAL_OBSERVATION. And since Remix doesn't show any details for reverts and view functions, you will just see a plain 'reverted'. The easiest way to test this is with the lowest granularity (= 2) and a reasonable window size (like 30 seconds).
3. The complex method: Moving Window Automatic
Lastly there is a cool project which implemented a solution where you don't need to have any automatic update()
calls.
How does that work conceptually?
Remember we need the historical value for price0CumulativeLast()
. This value is not on the chain anymore. So there is no way to just read it from the contract storage again. But there is something on the chain that relates to this value...
At least for the last 256 blocks, we still can read the blockhash from the EVM:
blockhash(uint blockNumber) returns (bytes32)
And now there is a little trick we can do. The resulting blockhash is the root of a merkle tree. Let me just try to give you the high-level idea on how this works.
This is a merkle tree.
At the root of the merkle tree is the root hash. This is what we can get using blockhash(uint blockNumber)
. It's created by hashing each data block and storing it as leaf node. Now two leaf hashes are combined by hashing those together. We do this all the way until we have one tree with a single root hash.
A merkle proof now would be proving to someone that L3 did indeed contain a given value. All one needs to do is provide the Hash 0, Hash 1-1 and the L3 block itself. Now for the proof verification one can compute the hash of L3, then the hash 1 and finally the top hash. We can then compare the root hash against our known root hash. For a visual explanation of a merkle proof, check out this great explanation.
In Ethereum one merkle tree is the state tree which contains all state like balances but also the contract storage. That means it will also contain our price0CumulativeLast
value. So we can create a merkle proof as described above for our historic price value! EIP-1186 introduced the eth_getProof
RPC call which gives you the required proof data automatically from a running Ethereum node. We can pass the proof data to the oracle contract and verify the proof inside the smart contract.
Check out the repository for the full details, but be aware that it's unaudited code.
Future Improvements
Solidity Developer