6. On-chain prices
Let's use Uniswap V2 to track on-chain prices
Goal
What's the price of ether? It's a central question to so many blockchain apps, but it's difficult to answer. It can be traded at different prices in different places, both off-chain and on-chain. Believers in efficient markets would hope arbitrage would align them, and, indeed, this was one of the earliest crypto arbitrage strategies. But there's enough complexity in pricing that many outsource it away to centralized third-party pricing APIs. That's a pity.
In this doc, we show you how to index your own prices using on-chain state. To do this, we'll track Uniswap V2 liquidity pools (LP). Each LP corresponds to a pair of tokens, and the price of one token in the pair relative to the other is a simple function of the ratio of the reserves. In tracking all pairs, we'll be able to express the spot price of any token relative to any other so long as an Uniswap V2 LP exists for the pair. There are many! You could even go further with multi-hop comparisons.
Finally, we'll check our work against the Uniswap V2 LP for WETH and USDC to get a current price of ether in US dollars.
If you want to skip to the end, here's a canvas that already has everything built.
Steps
Setting up the pipeline
-
Create a data source component. If you wanted all the data, you would set From to 10000835, as this is when Uniswap V2 was first deployed on Ethereum. If you're just creating this canvas for learning, please set the From to some block a few thousand blocks before the current tip block as it'll compute faster and reduce load on our systems.
-
Leave the To block blank as we'll let our code catch up to the tip and then continue to execute.
-
Add a Code component and connect it to the right of the data source. For now, leave it as is. We'll set up the code in a bit.
-
Add a schema component and connect it to the right of the code component. In the
Name
field, inputuniswap_v2_prices
and add the following fields:Name Type block_number uint256 token0 address token0_symbol string token0_decimals uint64 token1 address token1_symbol string token1_decimals uint64 reserve0 uint256 reserve1 uint256 -
Add a persistence component and connect it to the right of the schema component. Set the table name as
uniswap_v2_prices
and hit Set.
Writing the code
-
Open the Code IDE. Before we add our hooks, let's declare two internal variables directly within the contract that we'll use to track changes to LPs. The first is an array of addresses to track what pools have changed state in any given block. The second will be a mapping just to make sure we only add each pool to this list once per block, even if the pool's state changes in multiple swaps.
contract UserProbe is BaseDeclarativeProbe { address[] public poolsWithReservesUpdated; mapping(address => bool) poolVisited; ...
-
Next, we're going to add a Block hook because, for each block, we want to record all the LPs with changes and emit records for each. With Hook type set to Global hook add a Block hook. Choose the Post position, as we want to emit changes at the end of the block, and name the callback
postBlock
. -
To get the data we want, we need to call each LP that has swaps in any given block. For this, open the Interfaces sidebar and input the address for any Uniswap V2 LP (
0x3356c9A8f40F8E9C1d192A4347A76D18243fABC5
is one) and search. Once you've done this, you'll see the added interface both in the sidebar and the Interface code tab within the code editor. We can now use this interface to interact with Uniswap V2 LPs, which all have the same interface. To make our lives a little easier, let's rename the interface by replacingI0x3356c9A8f40F8E9C1d192A4347A76D18243fABC5
withIUniswapV2
. You can do this directly in the Interface code tab. -
We've added the
postBlock
hook and the interface we need. Within the hook callback, we want to iterate overpoolsWithReservesUpdated
. For each item, we want to:- Remove the pool from the
poolsWithReservesUpdated
array, so we don't reprocess the same update in the future and reset thepoolVisited[pool]
boolean. - Call the pool (using its interface) to get the following details at the end of the block:
_reserve0
,_reserve1
,- The reserves of each token (
reserve0
andreserve1
). - The address of each token (
token0
andtoken1
). - The symbol of each token (
token0_symbol
andtoken1_symbol
).
- The reserves of each token (
- Emit all the required data to the schema we defined earlier.
Here's code to do that:
contract UserProbe is BaseDeclarativeProbe { address[] public poolsWithReservesUpdated; mapping(address => bool) poolVisited; function postBlock() public { for (uint256 i = poolsWithReservesUpdated.length; i > 0; i--) { address pool = poolsWithReservesUpdated[i-1]; poolsWithReservesUpdated.pop(); delete poolVisited[pool]; (uint112 reserve0, uint112 reserve1,) = IUniswapV2(pool).getReserves(); address token0 = IUniswapV2(pool).token0(); string memory token0_symbol = IUniswapV2(token0).symbol(); uint8 token0_decimals = IUniswapV2(token0).decimals(); address token1 = IUniswapV2(pool).token1(); string memory token1_symbol = IUniswapV2(token1).symbol(); uint8 token1_decimals = IUniswapV2(token1).decimals(); simEmitToSchema_uniswap_v2_prices( block.number, // block_number token0, // token0 token0_symbol, // token0_symbol token0_decimals, // token0_decimals token1, // token1 token1_symbol, // token1_symbol token1_decimals, // token1_decimals reserve0, // reserve0 reserve1 // reserve1 ); } } }
- Remove the pool from the
-
The only piece we're missing now is tracking which LPs are active. Go back to the Hooks sidebar. Under Hook type select Input ABI via an address and input:
0x3356c9A8f40F8E9C1d192A4347A76D18243fABC5
. This is the address for an (arbitrarily selected) Uniswap V2 liquidity pool (LP). With this hook type, our code will execute for all addresses whose ABI contains the ABI of inputted address, meaning we're effectively hooking on all Uniswap V2 LPs. -
Under the function hooks, select
swap
and add it as a Post hook with a callback namedpostSwap
. Within its callback, we want to do two things: First, add the pool to the array of pools that have been updated calledpoolsWithReservesUpdated
. Second, set its booleanpoolVisited[pool]
to true. It only does this if it hasn't already been done. These variables are set within the scope of a block: if there are multiple swap operations on the same pool within the block, we need only do these operations once. Here's the code for this callback:function postSwap() public { postSwapContext storage ctx = getPostSwapContext(); address pool = ctx.txn.to; if (!poolVisited[pool]) { poolsWithReservesUpdated.push(pool); poolVisited[pool] = true; } }
Testing, executing, and querying
- Now that your code is complete, test it with the Test button at the top-right of the IDE. You can use the default range of 17000000 to 17000010 and should see Test logs like these:
-
Close the IDE using the
x
at the top-right to go back to the canvas. Now we're ready to run the full execution by hitting the play button. The code will start executing at the tip and backfilling from theFrom
block. You can see its status by mousing over the(?)
. -
Let's query the data! Uniswap V2 LPs work with ERC20 tokens. Neither ether nor USD are natively ERC20 tokens, of course, but each has an ERC20 pegged (in various ways) to it: Wrapped Ether and USDC, respectively. To get the latest price of Ether, all we need to do is find the latest reserve ratio, normalizing for their differing decimals (WETH has 18, whereas USDC has 6).
select block_number, token0_symbol, token1_symbol, (1.0 * reserve0 / POWER(10, token0_decimals)) / (1.0 * reserve1 / POWER(10, token1_decimals)) from $org.uniswap_v2_prices where token0 = x'A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' and token1 = x'C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' order by block_number desc limit 1
Key takeaways
- With block hooks and variable declaration, it's easy to perform some execution at the beginning or end of every block.
- You can condition the execution in one callback with state from another, as we did here with the postSwap callback.
- With interfaces, it's easy to interact with contracts directly within your execution.
- On-chain prices are easy!
While you're here
You could quite easily iterate this canvas further into a full backend for a pricing dApp:
- Query other prices.
- Query a history of price changes for a specific pair.
- Make prices queryable via an API.
- Push out price updates via a webhook.
- Add coverage for Uniswap V3 and/or other protocols.
Updated about 1 month ago