From af7ff62298a75f9f359552b331968640f44ebd03 Mon Sep 17 00:00:00 2001 From: VGabriel45 Date: Fri, 14 Mar 2025 09:24:42 +0200 Subject: [PATCH 1/2] feat: add positions tracking --- schema.graphql | 78 ++++++++++++ src/mappings/pool/burn.ts | 16 +++ src/mappings/pool/collect.ts | 30 ++++- src/mappings/pool/mint.ts | 20 ++- src/mappings/pool/swap.ts | 4 +- src/utils/position.ts | 229 ++++++++++++++++++++++++++++++++++ src/utils/positionSnapshot.ts | 32 +++++ 7 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 src/utils/position.ts create mode 100644 src/utils/positionSnapshot.ts diff --git a/schema.graphql b/schema.graphql index 3c4bb4a5..e8c64b30 100644 --- a/schema.graphql +++ b/schema.graphql @@ -74,6 +74,82 @@ type Token @entity { tokenDayData: [TokenDayData!]! @derivedFrom(field: "token") } +# New Position entity to track liquidity positions +type Position @entity { + # Format: --- + id: ID! + # Owner of the position + owner: Bytes! + # Pool the position is in + pool: Pool! + # Token0 of the pool + token0: Token! + # Token1 of the pool + token1: Token! + # Lower tick of the position + tickLower: BigInt! + # Upper tick of the position + tickUpper: BigInt! + # Current liquidity of the position + liquidity: BigInt! + # Deposited amount of token0 + depositedToken0: BigDecimal! + # Deposited amount of token1 + depositedToken1: BigDecimal! + # Withdrawn amount of token0 + withdrawnToken0: BigDecimal! + # Withdrawn amount of token1 + withdrawnToken1: BigDecimal! + # Collected fees of token0 + collectedFeesToken0: BigDecimal! + # Collected fees of token1 + collectedFeesToken1: BigDecimal! + # Transaction in which the position was created + transaction: Transaction! + # Timestamp when the position was created + createdAtTimestamp: BigInt! + # Block when the position was created + createdAtBlockNumber: BigInt! + # Timestamp when the position was last updated + updatedAtTimestamp: BigInt! + # Block when the position was last updated + updatedAtBlockNumber: BigInt! + # Closed position + closed: Boolean! +} + +# Position snapshot for historical data +type PositionSnapshot @entity { + # Format: - + id: ID! + # Owner of the position + owner: Bytes! + # Pool the position is in + pool: Pool! + # Position this is a snapshot of + position: Position! + # Block number of the snapshot + blockNumber: BigInt! + # Timestamp of the snapshot + timestamp: BigInt! + # Current liquidity of the position + liquidity: BigInt! + # Deposited amount of token0 + depositedToken0: BigDecimal! + # Deposited amount of token1 + depositedToken1: BigDecimal! + # Withdrawn amount of token0 + withdrawnToken0: BigDecimal! + # Withdrawn amount of token1 + withdrawnToken1: BigDecimal! + # Collected fees of token0 + collectedFeesToken0: BigDecimal! + # Collected fees of token1 + collectedFeesToken1: BigDecimal! + # Transaction hash of the snapshot + transaction: Transaction! +} + type Pool @entity { # pool address id: ID! @@ -139,6 +215,8 @@ type Pool @entity { swaps: [Swap!]! @derivedFrom(field: "pool") collects: [Collect!]! @derivedFrom(field: "pool") ticks: [Tick!]! @derivedFrom(field: "pool") + # positions in this pool + positions: [Position!]! @derivedFrom(field: "pool") } type Tick @entity { diff --git a/src/mappings/pool/burn.ts b/src/mappings/pool/burn.ts index 58ca1cbc..01e428c9 100644 --- a/src/mappings/pool/burn.ts +++ b/src/mappings/pool/burn.ts @@ -12,6 +12,8 @@ import { updateTokenHourData, updateUniswapDayData, } from '../../utils/intervalUpdates' +import { getOrCreatePosition, updatePositionWithBurn } from '../../utils/position' +import { createPositionSnapshot } from '../../utils/positionSnapshot' export function handleBurn(event: BurnEvent): void { handleBurnHelper(event) @@ -79,6 +81,20 @@ export function handleBurnHelper(event: BurnEvent, subgraphConfig: SubgraphConfi burn.tickUpper = BigInt.fromI32(event.params.tickUpper) burn.logIndex = event.logIndex + // Update position + const position = getOrCreatePosition( + event.transaction.from, + pool, + BigInt.fromI32(event.params.tickLower), + BigInt.fromI32(event.params.tickUpper), + event, + ) + updatePositionWithBurn(position, event.params.amount, amount0, amount1) + position.save() + + // Create position snapshot + createPositionSnapshot(position, event) + // tick entities const lowerTickId = poolAddress + '#' + BigInt.fromI32(event.params.tickLower).toString() const upperTickId = poolAddress + '#' + BigInt.fromI32(event.params.tickUpper).toString() diff --git a/src/mappings/pool/collect.ts b/src/mappings/pool/collect.ts index 89417947..d844a68b 100644 --- a/src/mappings/pool/collect.ts +++ b/src/mappings/pool/collect.ts @@ -12,6 +12,8 @@ import { updateTokenHourData, updateUniswapDayData, } from '../../utils/intervalUpdates' +import { getOrCreatePosition, updatePositionWithCollect } from '../../utils/position' +import { createPositionSnapshot } from '../../utils/positionSnapshot' import { getTrackedAmountUSD } from '../../utils/pricing' export function handleCollect(event: CollectEvent): void { @@ -23,11 +25,8 @@ export function handleCollectHelper(event: CollectEvent, subgraphConfig: Subgrap const whitelistTokens = subgraphConfig.whitelistTokens const bundle = Bundle.load('1')! - const pool = Pool.load(event.address.toHexString()) - if (pool == null) { - return - } - const transaction = loadTransaction(event) + const poolAddress = event.address.toHexString() + const pool = Pool.load(poolAddress)! const factory = Factory.load(factoryAddress)! const token0 = Token.load(pool.token0) @@ -36,6 +35,8 @@ export function handleCollectHelper(event: CollectEvent, subgraphConfig: Subgrap return } + const transaction = loadTransaction(event) + // Get formatted amounts collected. const collectedAmountToken0 = convertTokenToDecimal(event.params.amount0, token0.decimals) const collectedAmountToken1 = convertTokenToDecimal(event.params.amount1, token1.decimals) @@ -80,9 +81,26 @@ export function handleCollectHelper(event: CollectEvent, subgraphConfig: Subgrap factory.totalValueLockedETH = factory.totalValueLockedETH.plus(pool.totalValueLockedETH) factory.totalValueLockedUSD = factory.totalValueLockedETH.times(bundle.ethPriceUSD) + // Update position + if (event.params.owner) { + const position = getOrCreatePosition( + event.params.owner, + pool, + BigInt.fromI32(event.params.tickLower), + BigInt.fromI32(event.params.tickUpper), + event, + ) + updatePositionWithCollect(position, collectedAmountToken0, collectedAmountToken1) + position.save() + + // Create position snapshot + createPositionSnapshot(position, event) + } + + // collect entity const collect = new Collect(transaction.id + '-' + event.logIndex.toString()) collect.transaction = transaction.id - collect.timestamp = event.block.timestamp + collect.timestamp = transaction.timestamp collect.pool = pool.id collect.owner = event.params.owner collect.amount0 = collectedAmountToken0 diff --git a/src/mappings/pool/mint.ts b/src/mappings/pool/mint.ts index d3598368..47104adc 100644 --- a/src/mappings/pool/mint.ts +++ b/src/mappings/pool/mint.ts @@ -12,6 +12,8 @@ import { updateTokenHourData, updateUniswapDayData, } from '../../utils/intervalUpdates' +import { getOrCreatePosition, updatePositionWithMint } from '../../utils/position' +import { createPositionSnapshot } from '../../utils/positionSnapshot' import { createTick } from '../../utils/tick' export function handleMint(event: MintEvent): void { @@ -99,8 +101,8 @@ export function handleMintHelper(event: MintEvent, subgraphConfig: SubgraphConfi const lowerTickIdx = event.params.tickLower const upperTickIdx = event.params.tickUpper - const lowerTickId = poolAddress + '#' + BigInt.fromI32(event.params.tickLower).toString() - const upperTickId = poolAddress + '#' + BigInt.fromI32(event.params.tickUpper).toString() + const lowerTickId = poolAddress + '#' + event.params.tickLower.toString() + const upperTickId = poolAddress + '#' + event.params.tickUpper.toString() let lowerTick = Tick.load(lowerTickId) let upperTick = Tick.load(upperTickId) @@ -119,6 +121,20 @@ export function handleMintHelper(event: MintEvent, subgraphConfig: SubgraphConfi upperTick.liquidityGross = upperTick.liquidityGross.plus(amount) upperTick.liquidityNet = upperTick.liquidityNet.minus(amount) + // Update position + const position = getOrCreatePosition( + event.transaction.from, + pool, + BigInt.fromI32(event.params.tickLower), + BigInt.fromI32(event.params.tickUpper), + event, + ) + updatePositionWithMint(position, event.params.amount, amount0, amount1) + position.save() + + // Create position snapshot + createPositionSnapshot(position, event) + lowerTick.save() upperTick.save() diff --git a/src/mappings/pool/swap.ts b/src/mappings/pool/swap.ts index 3bf27c74..4fba3103 100644 --- a/src/mappings/pool/swap.ts +++ b/src/mappings/pool/swap.ts @@ -100,7 +100,7 @@ export function handleSwapHelper(event: SwapEvent, subgraphConfig: SubgraphConfi // Update the pool with the new active liquidity, price, and tick. pool.liquidity = event.params.liquidity - pool.tick = BigInt.fromI32(event.params.tick as i32) + pool.tick = BigInt.fromI32(event.params.tick) pool.sqrtPrice = event.params.sqrtPriceX96 pool.totalValueLockedToken0 = pool.totalValueLockedToken0.plus(amount0) pool.totalValueLockedToken1 = pool.totalValueLockedToken1.plus(amount1) @@ -171,7 +171,7 @@ export function handleSwapHelper(event: SwapEvent, subgraphConfig: SubgraphConfi swap.amount0 = amount0 swap.amount1 = amount1 swap.amountUSD = amountTotalUSDTracked - swap.tick = BigInt.fromI32(event.params.tick as i32) + swap.tick = BigInt.fromI32(event.params.tick) swap.sqrtPriceX96 = event.params.sqrtPriceX96 swap.logIndex = event.logIndex diff --git a/src/utils/position.ts b/src/utils/position.ts new file mode 100644 index 00000000..f596394f --- /dev/null +++ b/src/utils/position.ts @@ -0,0 +1,229 @@ +import { Address, BigDecimal, BigInt, ethereum } from '@graphprotocol/graph-ts' + +import { Pool, Position, Token } from '../../src/types/schema' +import { convertTokenToDecimal } from '.' +import { ZERO_BD, ZERO_BI } from './constants' + +class PositionAmounts { + amount0: BigDecimal + amount1: BigDecimal +} + +/** + * Gets or creates a position entity + * @param owner The owner of the position + * @param pool The pool address + * @param tickLower The lower tick of the position + * @param tickUpper The upper tick of the position + * @param event The event that triggered this function + * @returns The position entity + */ +export function getOrCreatePosition( + owner: Address, + pool: Pool, + tickLower: BigInt, + tickUpper: BigInt, + event: ethereum.Event, +): Position { + const positionId = owner.toHexString() + '-' + pool.id + '-' + tickLower.toString() + '-' + tickUpper.toString() + let position = Position.load(positionId) + + if (position === null) { + position = new Position(positionId) + position.owner = owner + position.pool = pool.id + position.token0 = pool.token0 + position.token1 = pool.token1 + position.tickLower = tickLower + position.tickUpper = tickUpper + position.liquidity = ZERO_BI + position.depositedToken0 = ZERO_BD + position.depositedToken1 = ZERO_BD + position.withdrawnToken0 = ZERO_BD + position.withdrawnToken1 = ZERO_BD + position.collectedFeesToken0 = ZERO_BD + position.collectedFeesToken1 = ZERO_BD + position.transaction = event.transaction.hash.toHexString() + position.createdAtTimestamp = event.block.timestamp + position.createdAtBlockNumber = event.block.number + position.closed = false + } + + position.updatedAtTimestamp = event.block.timestamp + position.updatedAtBlockNumber = event.block.number + + return position +} + +/** + * Updates a position with mint event data + * @param position The position to update + * @param amount The amount of liquidity added + * @param amount0 The amount of token0 added + * @param amount1 The amount of token1 added + */ +export function updatePositionWithMint( + position: Position, + amount: BigInt, + amount0: BigDecimal, + amount1: BigDecimal, +): void { + position.liquidity = position.liquidity.plus(amount) + position.depositedToken0 = position.depositedToken0.plus(amount0) + position.depositedToken1 = position.depositedToken1.plus(amount1) +} + +/** + * Updates a position with burn event data + * @param position The position to update + * @param amount The amount of liquidity removed + * @param amount0 The amount of token0 removed + * @param amount1 The amount of token1 removed + */ +export function updatePositionWithBurn( + position: Position, + amount: BigInt, + amount0: BigDecimal, + amount1: BigDecimal, +): void { + position.liquidity = position.liquidity.minus(amount) + position.withdrawnToken0 = position.withdrawnToken0.plus(amount0) + position.withdrawnToken1 = position.withdrawnToken1.plus(amount1) + + // Mark position as closed if liquidity is zero + if (position.liquidity.equals(ZERO_BI)) { + position.closed = true + } +} + +/** + * Updates a position with collect event data + * @param position The position to update + * @param amount0 The amount of token0 collected + * @param amount1 The amount of token1 collected + */ +export function updatePositionWithCollect(position: Position, amount0: BigDecimal, amount1: BigDecimal): void { + position.collectedFeesToken0 = position.collectedFeesToken0.plus(amount0) + position.collectedFeesToken1 = position.collectedFeesToken1.plus(amount1) +} + +/** + * Convert a tick to a sqrt price (sqrtPriceX96) + * @param tick The tick to convert + * @returns The sqrt price as a BigInt + */ +export function tickToSqrtPriceX96(tick: BigInt): BigInt { + // This is a simplified implementation + // In a real implementation, you would use the full math from Uniswap V3 + const tickNum = tick.toI32() + const price = Math.pow(1.0001, tickNum) + const sqrtPrice = Math.sqrt(price) + return BigInt.fromString((sqrtPrice * Math.pow(2, 96)).toString()) +} + +/** + * Calculate amount of token0 for a given liquidity + * @param sqrtRatioCurrentX96 The current sqrt price + * @param sqrtRatioUpperX96 The upper sqrt price + * @param liquidity The liquidity amount + * @returns The amount of token0 + */ +export function getAmount0ForLiquidity( + sqrtRatioCurrentX96: BigInt, + sqrtRatioUpperX96: BigInt, + liquidity: BigInt, +): BigInt { + if (sqrtRatioCurrentX96.ge(sqrtRatioUpperX96)) { + return ZERO_BI + } + + const numerator = liquidity.times(BigInt.fromI32(2).pow(96)).times(sqrtRatioUpperX96.minus(sqrtRatioCurrentX96)) + const denominator = sqrtRatioUpperX96.times(sqrtRatioCurrentX96) + + return numerator.div(denominator) +} + +/** + * Calculate amount of token1 for a given liquidity + * @param sqrtRatioLowerX96 The lower sqrt price + * @param sqrtRatioCurrentX96 The current sqrt price + * @param liquidity The liquidity amount + * @returns The amount of token1 + */ +export function getAmount1ForLiquidity( + sqrtRatioLowerX96: BigInt, + sqrtRatioCurrentX96: BigInt, + liquidity: BigInt, +): BigInt { + if (sqrtRatioCurrentX96.le(sqrtRatioLowerX96)) { + return ZERO_BI + } + + return liquidity.times(sqrtRatioCurrentX96.minus(sqrtRatioLowerX96)).div(BigInt.fromI32(2).pow(96)) +} + +/** + * Calculate the current amounts of token0 and token1 in a position + * This uses the Uniswap V3 math to calculate the amounts based on the current price + * @param position The position to calculate amounts for + * @param pool The pool the position is in + * @returns An object with amount0 and amount1 + */ +export function calculatePositionAmounts(position: Position, pool: Pool): PositionAmounts { + // If position is closed or has no liquidity, return zero + if (position.closed || position.liquidity.equals(ZERO_BI)) { + return { amount0: ZERO_BD, amount1: ZERO_BD } + } + + const token0 = Token.load(pool.token0)! + const token1 = Token.load(pool.token1)! + + // Get the current sqrt price from the pool + const sqrtPriceX96 = pool.sqrtPrice + + // Convert ticks to sqrt prices + const sqrtPriceX96Lower = tickToSqrtPriceX96(position.tickLower) + const sqrtPriceX96Upper = tickToSqrtPriceX96(position.tickUpper) + + // Calculate amounts using Uniswap V3 math + let amount0 = ZERO_BD + let amount1 = ZERO_BD + + if (pool.tick !== null) { + const currentTick = pool.tick as BigInt + + if (currentTick.lt(position.tickLower)) { + // Current price is below the position's range + // Only token0 is in the position + amount0 = convertTokenToDecimal( + getAmount0ForLiquidity(sqrtPriceX96Lower, sqrtPriceX96Upper, position.liquidity), + token0.decimals, + ) + } else if (currentTick.ge(position.tickUpper)) { + // Current price is above the position's range + // Only token1 is in the position + amount1 = convertTokenToDecimal( + getAmount1ForLiquidity(sqrtPriceX96Lower, sqrtPriceX96Upper, position.liquidity), + token1.decimals, + ) + } else { + // Current price is within the position's range + // Both tokens are in the position + amount0 = convertTokenToDecimal( + getAmount0ForLiquidity(sqrtPriceX96, sqrtPriceX96Upper, position.liquidity), + token0.decimals, + ) + amount1 = convertTokenToDecimal( + getAmount1ForLiquidity(sqrtPriceX96Lower, sqrtPriceX96, position.liquidity), + token1.decimals, + ) + } + } else { + // If tick is null, use a simplified approach + // Get current amounts by subtracting withdrawn from deposited + amount0 = position.depositedToken0.minus(position.withdrawnToken0) + amount1 = position.depositedToken1.minus(position.withdrawnToken1) + } + + return { amount0, amount1 } +} diff --git a/src/utils/positionSnapshot.ts b/src/utils/positionSnapshot.ts new file mode 100644 index 00000000..09ecccca --- /dev/null +++ b/src/utils/positionSnapshot.ts @@ -0,0 +1,32 @@ +import { ethereum } from '@graphprotocol/graph-ts' + +import { Position, PositionSnapshot } from '../../src/types/schema' + +/** + * Create a snapshot of a position + * @param position The position to snapshot + * @param event The event that triggered the snapshot + * @returns The position snapshot + */ +export function createPositionSnapshot(position: Position, event: ethereum.Event): PositionSnapshot { + const snapshotId = position.id + '-' + event.block.number.toString() + + const snapshot = new PositionSnapshot(snapshotId) + snapshot.owner = position.owner + snapshot.pool = position.pool + snapshot.position = position.id + snapshot.blockNumber = event.block.number + snapshot.timestamp = event.block.timestamp + snapshot.liquidity = position.liquidity + snapshot.depositedToken0 = position.depositedToken0 + snapshot.depositedToken1 = position.depositedToken1 + snapshot.withdrawnToken0 = position.withdrawnToken0 + snapshot.withdrawnToken1 = position.withdrawnToken1 + snapshot.collectedFeesToken0 = position.collectedFeesToken0 + snapshot.collectedFeesToken1 = position.collectedFeesToken1 + snapshot.transaction = event.transaction.hash.toHexString() + + snapshot.save() + + return snapshot +} From 0dd2b6529133f2ee7c0d47b8d37a2d9c11b76df3 Mon Sep 17 00:00:00 2001 From: Gabi <56271768+VGabriel45@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:25:16 +0200 Subject: [PATCH 2/2] Update README.md --- README.md | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/README.md b/README.md index 341acc9a..f0b93405 100644 --- a/README.md +++ b/README.md @@ -1,28 +1 @@ -# Uniswap V3 Subgraph - -### Subgraph Endpoint - -Synced at: https://thegraph.com/hosted-service/subgraph/ianlapham/uniswap-v3-subgraph?selected=playground - -Pending Changes at same URL - -### Running Unit Tests - -1. Install [Docker](https://docs.docker.com/get-docker/) if you don't have it already -2. Install postgres: `brew install postgresql` -3. `yarn run build:docker` -4. `yarn run test` - -### Adding New Chains - -1. Create a new subgraph config in `src/utils/chains.ts`. This will require adding a new `_NETWORK_NAME` const for the corresponding network. -2. Add a new entry in `networks.json` for the new chain. The network name should be derived from the CLI Name in The Graph's [supported networks documenation](https://thegraph.com/docs/en/developing/supported-networks/). The factory address can be derived from Uniswap's [deployments documentation](https://docs.uniswap.org/contracts/v3/reference/deployments/ethereum-deployments). -3. To deploy to Alchemy, run the following command: - -``` -yarn run deploy:alchemy -- - - --version-label - --deploy-key - --network -``` +# Uniswap V3 Subgraph with Positions indexing