Building a powerful Uniswap v3 dashboard with Shadow
Overview
Shadow collaborated with Uniswap on custom shadow events for Uniswap v3 pools, that unlock a much richer dataset for pool activity and liquidity dynamics.
We've built a dashboard (univ3.xyz) that leverages this dataset and our recently launched realtime database syncs to illustrate what you can build with Shadow.
In this post, we'll walk through these Uniswap v3 shadow events in detail, the net-new data they unlock, and how they're used to generate each dashboard chart.
What Shadow unlocks
Shadow gives you the superpower of adding offchain event logs to any deployed smart contract, and provides an easy-to-use hosted platform that eliminates the need for complicated data pipelines and other node infrastructure.
Shadow saves valuable time for top crypto engineering and data teams –– onchain data problems that used to take weeks are reduced to hours or even minutes.
With Shadow, one person can build a powerful onchain data indexer in an afternoon, without setting up any pipelines or infrastructure. This allows you to get products to market faster and make data-driven iterations more quickly (see how Pendle improved their trade routing by 34% with Shadow in this case study).
Uniswap v3 shadow events
In collaboration with Uniswap, we've developed shadow events for Uniswap v3 pools that unlock a much richer dataset for pool activity and liquidity dynamics.
This richer dataset allow us to:
- Create data heavy charts without making any RPC calls or using any external APIs
- Perform sophisticated analyses on liquidity provider behavior and profitability
- Construct a complete picture of liquidity distribution over price ranges and time
- Understand internal pool dynamics more deeply (e.g. activated ticks, liquidity in range, tick fee accrual)
But showing is better than telling. So we've built a dashboard – univ3.xyz – to illustrate some of what this unique shadow event dataset unlocks.
We've published this data for the USDC-WETH 5bps pool, and encourage researchers and analysts to share feedback and perform their own analyses.
You can read a detailed breakdown of each shadow event at the end of this post.
Chart explainers
This section outlines what each dashboard chart on univ3.xyz illustrates, and how Uniswap v3 pool shadow events were used to generate the data presented.
Volume & Fees
How shadow events are used:
- The
ShadowSwap
event contains a parameterUSDAmountE6
that uses the ETH-USD Chainlink price oracle to calculate the USD value of the swap with block-level accuracy, without the use of external offchain price APIs. - The USD amount of the swap is then multiplied by
poolFee
, from theShadowSwap
structPoolInfo
that also contains other useful pool metadata such as thepoolName
,tickSpacing
, and the address, symbol, name, and decimals oftoken0
andtoken1
.
Liquidity vs Price
How shadow events are used:
The PoolLiquidityAtTickSpace
shadow event is emitted whenever a liquidity mint
or burn
happens, and contains:
- The pool’s updated total liquidity at each tick-space that is/was part of the liquidity position, after the
mint
orburn
has been completed - The updated amount of net liquidity added when tick is crossed from left to right
This allows us to generate a view of the pool’s liquidity distribution at any given block height, without making any RPC calls.
LP Positions
How shadow events are used:
The ShadowMint
and ShadowBurn
events contain an owner
and a sender
parameter, which disambiguates the EOA who initiated the transaction (owner
) and the sender
who called the pool function (often the Uniswap NonfungiblePositionManager
contract).
Then, a positionId
hash is generated from the combination of owner
, tickLower
, and tickUpper
. This allows us to map mints and burns that are associated with a single owner without making RPC calls, and regardless of whether the liquidity position was changed using the NonfungiblePositionManager
contract.
Lastly, we use the last ShadowSwap
at the selected block height to calculate the USD value of the tokens in each liquidity position.
Liquidity Changes
How shadow events are used:
The ShadowMint
and ShadowBurn
events contain a struct TokenInfo
that stores metadata such as tokenAddress
, tokenSymbol
, tokenName
, and tokenDecimals
, which simplify the presentation of human-readable token amounts in the chart.
Fees per Tick Range
How shadow events are used:
The ShadowMint
and ShadowBurn
events contain an owner
and a sender
parameter, which disambiguates the EOA who initiated the transaction (owner
) and the sender
who called the pool function (often the Uniswap NonfungiblePositionManager
contract).
Then, a positionId
hash is generated from the combination of owner
, tickLower
, and tickUpper
. This allows us to map mints and burns that are associated with a single owner without making RPC calls, and regardless of whether the liquidity position was changed using the NonfungiblePositionManager
contract.
We aggregate the total swap volume in USD per tick space across the specified time period, and multiply it by poolFee
to show the fees accrued per tick.
Fees Collected
How shadow events are used:
The ShadowMint
and ShadowBurn
events contain an owner
and a sender
parameter, which disambiguates the EOA who initiated the transaction (owner
) and the sender
who called the pool function (often the Uniswap NonfungiblePositionManager
contract).
Then, a positionId
hash is generated from the combination of owner
, tickLower
, and tickUpper
. This allows us to map mints and burns that are associated with a single owner without making RPC calls, and regardless of whether the liquidity position was changed using the NonfungiblePositionManager
contract.
The ShadowBurn event also includes FeesEarned.token0 and FeesEarned.token1 parameters, which breaks out the swap fees accrued to the liquidity position from the original token0 and token1 amounts deposited into the pool.
Top LPs by Fees Collected
How shadow events are used:
The ShadowMint
and ShadowBurn
events contain an owner
and a sender
parameter, which disambiguates the EOA who initiated the transaction (owner
) and the sender
who called the pool function (often the Uniswap NonfungiblePositionManager
contract).
Then, a positionId
hash is generated from the combination of owner
, tickLower
, and tickUpper
. This allows us to map mints and burns that are associated with a single owner without making RPC calls, and regardless of whether the liquidity position was changed using the NonfungiblePositionManager
contract.
The ShadowBurn
event contains FeesEarned.token0
and FeesEarned.token1
parameters, which breaks out the swap fees accrued to the liquidity position from the original token0
and token1
amounts deposited into the pool.
Data explainers
This section details the data contained in each shadow event for the Uniswap v3 pool contracts. We presume that you are familiar with the basic concepts of Uniswap v3. Read these posts if you need a refresher or want to get up to speed:
Notation
Because Solidity doesn’t support decimals natively, we use E
notation to represent values that are multiplied by 1eN
in order to preserve precision. Divide by 1eN
when presenting the value to users.
- For example: if
USDAmountE6
= 1,245,480,000, then you should divide it by1e6
to arrive at the human readable value of 1,245.48
Some other values in the Uniswap contract, such as sqrtPriceX96
, are represented by Q notation, which you can read more about here.
Structs
We use structs liberally to keep data params organized and avoid the Solidity compiler Stack too deep
error. Shadow removes gas accounting and contract code size limits, so you can add as much data logging as you want.
SwapInfo
// struct for information about a swap
struct SwapInfo {
// The address that initiated the swap call, and that received the callback
address sender;
// The address that received the output of the swap
address recipient;
// The delta of the token0 balance of the pool
int256 amount0;
// The delta of the token1 balance of the pool
int256 amount1;
// The USD amount of the swap using the Chainlink oracle, multiplied by 1e6 for downstream storage precision
uint256 USDAmountE6;
// The sqrt(price) of the pool after the swap, as a Q64.96
uint160 sqrtPriceX96;
// The liquidity of the pool after the swap
uint128 liquidity;
// The log base 1.0001 of price of the pool after the swap
int24 tick;
}
TokenInfo
// struct for general token information
struct TokenInfo {
// token address
address tokenAddress;
// token symbol, e.g. "WETH"
string tokenSymbol;
// token name, e.g. "Wrapped Ether"
string tokenName;
// token decimals
uint8 tokenDecimals;
}
PoolInfo
// struct for general pool information
struct PoolInfo {
// token0 of the pool
TokenInfo token0;
// token1 of the pool
TokenInfo token1;
// fee for the pool, expressed in hundredths of a bip, i.e. 1e-6
uint24 poolFee;
// Shadow's name for the pool; in format "token0Symbol-token1Symbol poolFee bps"
string poolName;
// the tick spacing for the pool
int24 tickSpacing;
}
PositionDetails
// struct for general liquidity position information
struct PositionDetails {
// owner of the liquidity position; if minted through NonfungiblePositionManager this is msg.sender to mint() function of NonfungiblePositionManager
address owner;
// sender of the mint() function on the pool contract; may be different than owner, and will be NonfungiblePositionManager if it was used
address sender;
// The lower tick of the position
int24 tickLower;
// The upper tick of the position
int24 tickUpper;
// The amount of liquidity minted to the position range
uint128 amount;
// How much token0 was required for the minted liquidity
uint256 amount0;
// How much token1 was required for the minted liquidity
uint256 amount1;
// The Shadow positionId generated from keccak256 hash of owner, tickLower, and tickUpper
bytes32 positionId;
}
FeesEarned
// struct for fees earned, used in ShadowBurn event
struct FeesEarned {
// the fees earned of token0
uint128 token0;
// the fees earned of token1
uint128 token1;
}
PositionFeeValues
// struct for liquidity position fee values; helpful for calculating position fees, used in ShadowMint and ShadowBurn events
struct PositionFeeValues {
// feeGrowthGlobal0E18 The fee growth global of token0, where feeGrowthGlobal0E18 = (feeGrowthGlobal0X128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthGlobal0E18;
// feeGrowthGlobal1E18 The fee growth global of token1, where feeGrowthGlobal1E18 = (feeGrowthGlobal1X128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthGlobal1E18;
// the fee growth outside of the upper tick of token0, where feeGrowthOutsideUpper0E18 = (feeGrowthOutsideUpper0X128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthOutsideUpper0E18;
// the fee growth outside of the upper tick of token0, where feeGrowthOutsideLower0E18 = (feeGrowthOutsideLower0X128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthOutsideLower0E18;
// the fee growth inside the tick range as of the last mint/burn/poke of token0, where feeGrowthInside0LastE18 = (feeGrowthInside0LastX128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthInside0LastE18;
// the fee growth outside of the upper tick of token1, where feeGrowthOutsideUpper1E18 = (feeGrowthOutsideUpper1X128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthOutsideUpper1E18;
// the fee growth outside of the upper tick of token1, where feeGrowthOutsideLower1E18 = (feeGrowthOutsideLower1X128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthOutsideLower1E18;
// the fee growth inside the tick range as of the last mint/burn/poke of token1, where feeGrowthInside1LastE18 = (feeGrowthInside1LastX128 * 10**18) >> 128 to allow for downstream data storage with precision
uint256 feeGrowthInside1LastE18;
}
LiquidityInRangeValues
// struct for liquidity in range values, used in ShadowMint and ShadowBurn events
struct LiquidityInRangeValues {
// the liquidity in range from the position
uint128 positionLiquidityInRange;
// the total liquidity in range before the event
uint128 totalLiquidityInRangeBefore;
// the total liquidity in range after the event
uint128 totalLiquidityInRangeAfter;
}
Shadow events
ShadowSwap
We’ve expanded on the original Swap
event to:
- Add
freeGrowthGlobal
, which allows us to see how each swap contributes to pool fees - Add
USDAmountE6
of the swap, based on the Chainlink oracle price of ETH-USD at the time of the swap - Add useful metadata in the
SwapInfo
andPoolInfo
structs such as token symbol and token decimals.
/// @notice Emitted by the pool for any swaps between token0 and token1
/// @param swapInfo Struct with basic info about the swap
/// @param poolInfo Struct with basic info about the pool
/// @param feeGrowthGlobal0E18 The fee growth global of token0, where feeGrowthGlobal0E18 = (feeGrowthGlobal0X128 * 10**18) >> 128 to allow for downstream data storage with precision
/// @param feeGrowthGlobal1E18 The fee growth global of token1, where feeGrowthGlobal1E18 = (feeGrowthGlobal1X128 * 10**18) >> 128 to allow for downstream data storage with precision
event ShadowSwap(
SwapInfo swapInfo,
PoolInfo poolInfo,
uint256 feeGrowthGlobal0E18,
uint256 feeGrowthGlobal1E18,
int256 chainlinkETHUSDPriceE8
);
TickCrossed
This event does not exist at all in the original contract. It is emitted when a swap causes the pool price to move enough to cross over into another price-tick. If the swap amount is large enough, multiple ticks can be crossed, and so multiple TickCrossed
events would be emitted.
The TickCrossed
event includes all of the state of the tick that was newly crossed into. This data is critical to have a complete view into the pool’s liquidity state at any given time, and is especially helpful for analysis looking at LP profitability.
/// @notice Emitted when a tick is crossed
/// @param tick The index of the tick that was crossed
/// @param liquidityGross The total position liquidity that references this tick
/// @param liquidityNet Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left)
/// @param feeGrowthOutside0E18 The token0 fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick). Only has relative meaning, not absolute — the value depends on when the tick is initialized. feeGrowthOutside0E18 = (feeGrowthOutside0X128 * 10**18) >> 128 to allow for downstream data storage with precision
/// @param feeGrowthOutside1E18 The token1 fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick). Only has relative meaning, not absolute — the value depends on when the tick is initialized. feeGrowthOutside1E18 = (feeGrowthOutside1X128 * 10**18) >> 128 to allow for downstream data storage with precision
/// @param tickCumulativeOutside The cumulative tick value on the other side of the tick
/// @param secondsPerLiquidityOutsideX128 The seconds per unit of liquidity on the _other_ side of this tick (relative to the current tick). Only has relative meaning, not absolute — the value depends on when the tick is initialized
/// @param secondsOutside The seconds spent on the other side of the tick (relative to the current tick). Only has relative meaning, not absolute — the value depends on when the tick is initialized
event TickCrossed(
int24 indexed tick,
uint128 liquidityGross,
int128 liquidityNet,
uint256 feeGrowthOutside0E18,
uint256 feeGrowthOutside1E18,
int56 tickCumulativeOutside,
uint160 secondsPerLiquidityOutsideX128,
uint32 secondsOutside
);
ShadowMint
We’ve expanded on the original Mint
event to:
- Add values like
feeGrowthGlobal
,feeGrowthOutside
, andfeeGrowthInsideLast
within thePositionFeeValues
struct, which enable us to calculate the uncollected fees of a position. - Add values about liquidity in range vs out, within the
LiquidityInRangeValues
struct - Add some useful metadata about the position, and the Chainlink oracle ETH-USD price at the block
The additional values included in the ShadowMint
event are important for analysis into liquidity provider profitability, and what drives their behavior and strategies.
/// @notice Emitted when liquidity is minted for a given position
/// @param positionDetails struct with information about owner, sender, tick range, liquidity and token amounts, and Shadow positionId
/// @param positionFeeValues struct with information about fee growth global, fee growth outside, and fee growth inside of token0 and token1, helpful for calculating position fees
/// @param liquidityInRangeValues struct with information about position liquidity in range, and total liquidity in range before and after the event
/// @param poolInfo Struct with basic info about the pool
/// @param tick The log base 1.0001 of price of the pool after the mint
event ShadowMint(
PositionDetails positionDetails,
PositionFeeValues positionFeeValues,
LiquidityInRangeValues liquidityInRangeValues,
PoolInfo poolInfo,
int24 tick,
int256 chainlinkETHUSDPriceE8
);
ShadowBurn
We’ve expanded on the original Burn
event to:
- Add values like
feeGrowthGlobal
,feeGrowthOutside
, andfeeGrowthInsideLast
within thePositionFeeValues
struct, which enable us to calculate the uncollected fees of a position - Add the fees earned by the position, which is not broken out in the original contract’s
Burn
event - Add values about liquidity in range vs out, within the
LiquidityInRangeValues
struct - Add some useful metadata about the position, and the Chainlink oracle ETH-USD price at the block
The additional values included in the ShadowBurn
event are important for analysis into liquidity provider profitability, and what drives their behavior and strategies.
/// @notice Emitted when a position's liquidity is removed
/// @dev Does not withdraw any fees earned by the liquidity position, which must be withdrawn via #collect
/// @param positionDetails struct with information about owner, sender, tick range, liquidity and token amounts, and Shadow positionId
/// @param feesEarned struct with information about fees earned by the position; subtract feesEarned from amount0 and amount1 to calculate token0 and token1 used to mint position
/// @param positionFeeValues struct with information about fee growth global, fee growth outside, and fee growth inside of token0 and token1, helpful for calculating position fees
/// @param liquidityInRangeValues struct with information about position liquidity in range, and total liquidity in range before and after the event
/// @param poolInfo Struct with basic info about the pool
/// @param tick The log base 1.0001 of price of the pool after the burn
event ShadowBurn(
PositionDetails positionDetails,
FeesEarned feesEarned,
PositionFeeValues positionFeeValues,
LiquidityInRangeValues liquidityInRangeValues,
PoolInfo poolInfo,
int24 tick,
int256 chainlinkETHUSDPriceE8
);
ShadowCollect
We’ve expanded on the original Collect
event to:
- Add the Chainlink oracle ETH-USD price at the block
/// @notice Emitted when fees are collected by the owner of a position
/// @dev Collect events may be emitted with zero amount0 and amount1 when the caller chooses not to collect fees
/// @param owner The owner of the position for which fees are collected; if called through NonfungiblePositionManager, will be msg.sender to NonfungiblePositionManager
/// @param recipient The recipient of the fees that are collected
/// @param tickLower The lower tick of the position
/// @param tickUpper The upper tick of the position
/// @param amount0 The amount of token0 fees collected
/// @param amount1 The amount of token1 fees collected
event ShadowCollect(
address indexed owner,
address recipient,
int24 indexed tickLower,
int24 indexed tickUpper,
uint128 amount0,
uint128 amount1,
int256 chainlinkETHUSDPriceE8
);
PoolLiquidityAtTickSpace
This event does not exist at all in the original contract. It is emitted when a liquidity mint
or burn
happens, and contains:
- The pool’s updated total liquidity at each tick-space that is/was part of the liquidity position, after the
mint
orburn
has been completed - The updated amount of net liquidity added when tick is crossed from left to right
At least one PoolLiquidityAtTickSpace
event is emitted for each liquidity position mint
or burn
, and most of the time multiple PoolLiquidityAtTickSpace
events will be emitted for one liquidity position mint
or burn
, as most liquidity positions span across multiple tick-spaces.
The PoolLiquidityAtTickSpace
events are critical to generate a view of the pool’s liquidity distribution at any given block height.
/// @notice Emitted on liquidity mints and burns; includes pool liquidity at each tick space of the position
/// @param tickSpaceLower The lower bound of the tick space; each tick space is the size of tickSpacing (e.g. if tickSpacing = 10, the tick space with tickSpaceLower of 201250 spans from [201250, 201260))
/// @param poolLiquidityAtTickSpace the liquidity of the pool if the active tick was within [tickSpaceLower, tickSpaceLower + tickSpacing)
/// @param liquidityNet Amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left)
/// @param poolInfo Struct with basic info about the pool
/// @param positionId The Shadow positionId generated from keccak256 hash of owner, tickLower, and tickUpper
event PoolLiquidityAtTickSpace(
int24 tickSpaceLower,
uint128 poolLiquidityAtTickSpace,
int128 liquidityNet,
PoolInfo poolInfo,
bytes32 positionId
);
Conclusion
Shadow gives you the superpower of adding offchain event logs to any deployed smart contract, through an easy-to-use hosted platform that eliminates the need for complicated data pipelines and other node infrastructure.
Shadow events bring clear benefits:
- Deeper data coverage: Generate net-new events by accessing onchain data that was previously inaccessible (or very difficult to access), on any smart contract.
- Simplified data pipelines: Drastically reduce the complexity of data pipelines by writing transformation logic directly in smart contracts themselves.
- Faster iteration cycles: Quickly test, verify, and iterate on shadow events using tools that you’re already familiar with.
- Permissionless, gasless logging: Permissionlessly add as many events as you want, on any contract you want, without increasing the gas burden on end users.
With Shadow, one person can build a powerful onchain data indexer in an afternoon, without setting up any pipelines or infrastructure. This allows you to get products to market faster and make data-driven iterations more quickly (see how Pendle improved their trade routing by 34% with Shadow in this case study).
We've built a dashboard that leverages this dataset and our recently launched realtime database syncs to illustrate what you can build with Shadow.
Thank you to Austin Adams and Dan Robinson for their collaboration on these shadow events, Achal for designing the dashboard, and Ciamac Moallemi, saucepoint, Alex Nezlobin, Storm, and others for providing feedback on univ3.xyz.