The initial Uniswap Grants was announced here
Overview
Chaos Labs is a cloud platform that specializes in Economic Security for DeFi protocols. At Chaos Labs we build agent- and scenario- based simulations to battle test protocols in adversarial and chaotic (🤗) market conditions. In our quest to build state of the art simulations and security tooling we also partner with DeFi protocols to build valuable tools for the DeFi developer ecoysystem. This series of posts is a technical deep dive on Uniswap v3 TWAP oracles. Our implementation for configuring Uniswap v3 TWAP return values can be found here. PRs are welcome!
Dependencies Everywhere
DeFi protocols are composable (money legos) by nature. The upside of composability is creativity and faster development iterations. The downside is that composability comes at a price. Each module or protocol that a dApp consumes is a new depenedency.
Each dependency is an attack vector. Every vector is an opportunity to dictate and manipulate internal application state in unexpected ways.
Oracles as attack vectors
Oracles are one of the largest risks to application security and integrity. Oracles provide data which are consumed by applications as an absolute source of truth. If we view applications as a state machine with different parameters, many critical states are only reachable as functions of values returned by an oracle.
For this reason, Chaos Labs invests a lot of resources into building robust tooling for interfacing with oracles. The impact of building tooling to configure oracles is two-fold:
- Economic Security - By controlling the return values of oracles we can accurately simulate black swan events, cascading asset prices and market volatility.
- Cyber Security - Contracts that hold billions of dollars in assets cannot afford the luxury of a safe oracle assumption. We now have the ability to configure malicous or faulty oracle return values and test the response of various applications.
This series of blog post focuses on Uniswap v3 TWAP Oracles. Part 1 is an introduction and the following posts will be a technical deep dive.
Oracles: Onchain vs. Offchain
Before jumping into the technical let’s quickly review the difference between onchain and offchain oracles.
Off-Chain Oracles
Node operators submit a price. A medianization process is triggered which results in a price consensus or average, usually through a decentralized trusted network. From there the different dApps can consume that data and trust it to be honest as long as they trust the integrity of the Oracles.
We’re going to review this architecture in-depth in our up coming series about Chainlink Oracles. Stay tuned!
On-Chain
Derive the information (prices) based on on-chain information like the ratio of supply and demand in a trading (AMM) pool. A good example for this type of Oracles is Uniswap V3 TWAP Oracles.
Uniswap V3 TWAP Oracles
All Uniswap V3 pair pools can serve as price oracles, offering access to historical price and liquidity data based on trades in the pool.
Historical data is stored as an array of Observations structs. Like their name suggests, Observations store the information observed by the Pool when a trade is executed. Observations are written to memory on a per block basis (if a trade occurs). Using two Observations we can derive the time weighted average price, amongst other things.
First lets look into what each observation instance stores:
struct Observation { // the block timestamp of the observation uint32 blockTimestamp; // the tick accumulator, i.e. tick * time elapsed since the pool was first initialized int56 tickCumulative; // the seconds per liquidity, i.e. seconds elapsed / max(1, liquidity) since the pool was first initialized uint160 secondsPerLiquidityCumulativeX128; // whether or not the observation is initialized bool initialized; }
So where’s the price 🤔? The price can be derived from the tickCumulative Let’s understand what ticks are and how they’re used to calculate prices.
Ticks & Price
In traditional finance, a tick is a measure of the minimum upward or downward movement of the price of a security. Uniswap v3 employs a similar idea: compared to the previous/next price, the minimal price change should be 0.01% = 1 basis point. Resulting in the following method to derive the price from a tick:
Price(tick) = 1.0001^tick
We know what a tick is, but what’s a tickCumulative? - the tick multiplied by seconds elapsed for the life of the pool as of the observation timestamp.
So how do we use the tickCumulative from the observations to get the price? As the name TWAP (Time Weighted Average Price) suggests we can calculate the (average) price over a period time, for that we would need 2 observations, two reference points to conclude an average value from. Using the 2 observations we can calculate the average tick and then using the formula above get the average price. Let’s use an example:
Let’s say we have
- 2 observations, 10 seconds apart
- tickCumulatives
- t1: 100,000
- t2: 200,000
The average tick would then be:
$(200,000-100,000)/10 = 10,000.$
The average price over that time period is:
$Price(tick) == Price(10,000) === 1.0001 ** (10,000) ≅ 2.7181.$
Why Should I care about TWAP Oracles?
So we know how to calculate the prices from two observations but how do we get the price from the Uniswap V3 Pool and why would we choose to use TWAP oracles?
Let’s first start with the ‘why’ - because TWAP oracles are very hard to manipulate by an attacker assuming enough liquidity in a given pool. How do we measure the difficulty of an attacker to influence the price in a TWAP oracle? There are two factors that we use to determine the attack difficulty:
- Funds
- Time
Funds
The amount of money the attacker has to pour into the trading pool in order to move the price in the direction they desire. If the pool has deep liquidity, that amount can be in the hundreds of millions and more, but that’s not enough. With Flash Loans attackers can, in the scope of a single block, get access to these funds. So how do we protect against it?
Time
interfacing with the TWAP oracles requires the user to pick the time frame for the desired price. So let’s say we ask for the average weighted price over a period of 5 minutes. Attackers attempting to change the price in pool to a desired price X, need to maintain that price for 5 minutes.
But Flash Loans cannot be extended over a single block. So now the attacker needs to keep the desired price over multiple blocks and therein lies the catch. Now the attacker needs to provide real liquidity. So let’s say our ambitious hacker ponies up the funds. Once a block has been mined everyone on the network can see the price manipulation. Any deviation in price is a ripe opportunity for arbitrage. Multiple bots will fight over the opportunity to close this arb. Now the attacker has to drive the price against market, dumping more money into the pool and competing against arbitrageurs.
In case you want more precise figures on how difficult it is for an attacker to manipulate TWAP oracles I recommend reading this research paper Manipulating Uniswap v3 TWAP Oracles by Michael Bentley.
Ok, TWAPs are secure, but how do I use them?
Now that we understand how TWAP oracles work and why they’re difficult to manipulate, let’s dive into how we work with them.
The first step is to select a time interval when querying a TWAP oracle. Longer intervals can make us more resilient to potential attackers or extremely volatile price changes. The tradeoff of longer intervals is that prices are less ‘fresh’ - meaning that current spot price can be a bit different from the average weighted price over 5 minutes if the price was continuously changing during that time frame. Because of this, picking the right interval should be based on tradeoffs your application is willing to make. Do you need the most current price at expense of being sensitive to price spikes? Or do you prioritize the weighted average over precision?
For the sake of the example we will pick a TWAP interval of 5 minutes - 300 seconds. Notice that the interval is counted in seconds on Uniswap Oracles.
Now we need 2 Observations with a 300 seconds interval between them, the first needs to be the latest observed. How do we get these? Pretty simple, let’s look at the Uniswap V3 Pool docs:
Ok so we can call Observe() or observations(), what’s the difference?
observations() - the Pool holds a (changeable up to 80) number of observations. The observations function will allow us to access them by index. However, that doesn’t help much because the observations are placed in a cyclic array, meaning that the latest observation can be anywhere depending on the Pool state. We can get that information from the Slot0 data structure but we we’ll touch on that later.
observe() - is more suitable for our needs for 2 reasons. The interface to the observe function allows us to provide an array of secondsAgo - seconds that passed from the current block timestamp. In our example calling Observe([0,300] will give us 2 Observations with 300 seconds apart, exactly what we needed!
But it’s not all it does, and that’s the cool part. Like we mentioned the Pool records an observation per block if a trade occurred within that block. So what happens if there wasn’t any trade recently or at the specified time we want? That’s an issue - we can’t be sure that we have an observation on the latest block or the block with a timestamp of exactly 300 seconds ago. To solve for this the contract will use a binary search algorithm to find the closet observations to the desired timestamps and will then extrapolate based on the closet observations to return an estimated observation at the requested timestamps.
Great, so the observe function is what we need. It will return the extrapolated tickCumulatives for the requested secondsAgo array input - giving us all the data we need to fetch the price. Now we just need to calculate the price using our knowledge of how prices are calculated from ticks.
To make our life easier the code snippet below serves as an implementation reference to fetch the caclulated price directly from any Uniswap v3 Oracle:
import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; import "@uniswap/v3-core/contracts/libraries/TickMath.sol"; import "@uniswap/v3-core/contracts/libraries/FixedPoint96.sol"; import "@uniswap/v3-core/contracts/libraries/FullMath.sol"; contract TWAPPriceGetter { function getPrice(address poolAddress, uint32 twapInterval) public view returns (uint256 priceX96) { if (twapInterval == 0) { (sqrtPriceX96, , , , , , ) = IUniswapV3Pool(poolAddress).slot0(); return FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, FixedPoint96.Q96); } else { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = twapInterval; secondsAgos[1] = 0; (int56[] memory tickCumulatives, ) = IUniswapV3Pool(poolAddress).observe(secondsAgos); uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick( int24((tickCumulatives[1] - tickCumulatives[0]) / twapInterval) ); return FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, FixedPoint96.Q96); } } }
As we can see in the code above we handle the edge case of a 0 seconds twapInteval by fetching the current tick saved on Slot0 data in the contract, though we don’t recommend using TWAP oracles like that. Assuming a twapInterval that is bigger than we create the input secondsAgo array for the observe functions and fetch the tickCumulatives. We then use Uniswap utility functions to get the price out of the average weighted tick.
Notice that prices on Uniswap v3 are always scaled up by 2⁹⁶, which is the reason why I add the suffix X96 to all price-related variables.
In case you want to get the price of Uniswap V3 TWAP oracles on Javascript applications Uniswap has provided the https://github.com/Uniswap/v3-sdk with the same utility functions provided for the v3-core.
What's next? ⏭️
Our next post will explore TWAP architecture as well as our implementation for configuring the return values of Uniswap v3 TWAP Oracles in a development environment. Stay tuned!
About the Uniswap Grant Program
If you want to learn more about the Uniswap Grants Program, check out their blog.