Building a powerful Uniswap v3 dashboard with Shadow

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.

💡
Capital loss metrics such as loss vs rebalancing (LVR) impermanent loss (IL) were not included in this version of the dashboard. We'd love to add this category of the data in the future.

Volume & Fees

Bars represent swap volume and line represents fees collected.

How shadow events are used:

  • The ShadowSwap event contains a parameter USDAmountE6 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 the ShadowSwap struct PoolInfo that also contains other useful pool metadata such as the poolName, tickSpacing, and the address, symbol, name, and decimals of token0 and token1.

Liquidity vs Price

Virtual liquidity distribution in the pool across a tick-price range, at a given block. Control at the bottom allows scrub through past block ranges. You can also enter a specific block number.

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 or burn 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

The largest liquidity positions across a tick-price range, at a given block.

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

The token0/1 liquidity changes in the pool, over a given time period.

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

The fees earned by the entire pool across a tick-price range, over a given time period.

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

The fees collected by individual liquidity positions, over a given time period.

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

The top liquidity providers by fees collected, over a given time period.

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:

💡
Capital loss metrics such as loss vs rebalancing (LVR) impermanent loss (IL) are not included this set of shadow events. We encourage researchers to use Shadow to capture data about these metrics!

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 by 1e6 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.

💡
Some structs are re-used within multiple shadow events, which keeps naming consistent and reduces the need for extra table joins.

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 and PoolInfo 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, and feeGrowthInsideLast within the PositionFeeValues 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, and feeGrowthInsideLast within the PositionFeeValues 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 ShadowBurnevent 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 or burn 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:

  1. Deeper data coverage: Generate net-new events by accessing onchain data that was previously inaccessible (or very difficult to access), on any smart contract.
  2. Simplified data pipelines: Drastically reduce the complexity of data pipelines by writing transformation logic directly in smart contracts themselves.
  3. Faster iteration cycles: Quickly test, verify, and iterate on shadow events using tools that you’re already familiar with.
  4. 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.