A Graph subgraph for Spry: Uniswap V4 plus one
custom hook (SpryHook). It is a focused fork of the official
Uniswap/v4-subgraph: ~90% of it
(pools, positions, ticks, tokens, swaps, liquidity, TVL/volume, day/hour
aggregates) is inherited verbatim, with targeted additions to (a) index only
Spry pools, (b) surface Spry's headline metric, the per-swap dynamic fee,
and (c) index the hook's own SpryFee event: the signed block-windowed
cumulative, the curve zone, and the dispatch case.
Status: pre-mainnet and pre-audit, supporting Unichain Sepolia and Base Sepolia. See Configure for your deployment.
Query endpoints (Goldsky):
- Unichain Sepolia:
https://api.goldsky.com/api/public/project_cmls3noc9jy1l01uy0cr74jok/subgraphs/spry-subgraph-unichain-sepolia/1.0.0/gn- Base Sepolia:
https://api.goldsky.com/api/public/project_cmls3noc9jy1l01uy0cr74jok/subgraphs/spry-subgraph-base-sepolia/1.0.0/gn
git clone <repo-url> spry-subgraph && cd spry-subgraph
yarn install
yarn build # = graph codegen && graph build (compiles to ./build)
yarn test # 79 matchstick unit tests (no Docker needed: npx graph test)yarn build and yarn test work out of the box against the placeholder config.
Before deploying to a real network, set your addresses (one src/utils/spry.ts
edit + one networks.json edit). See
Configure for your deployment and
Build & deploy.
Spry deploys a single hook (SpryHook) on top of the canonical, unmodified
Uniswap V4 PoolManager and PositionManager. A Spry pool is simply a V4
pool whose:
hooksfield equals the SpryHook address, andfeefield is the V4 dynamic-fee sentinel0x800000(DYNAMIC_FEE_FLAG).
On every swap, SpryHook.beforeSwap returns a per-swap LP fee (OR-ed with
OVERRIDE_FEE_FLAG 0x400000); V4 applies it and emits the resolved fee in the
Swap event's fee field. Because the V4 core is unchanged, the bulk of the
Uniswap subgraph applies as-is.
Each Spry pool also has a tier, determined solely by its tickSpacing:
| tickSpacing | tier | base fee | cap fee | base pips | cap pips |
|---|---|---|---|---|---|
| 1 | STABLE |
0.01% | 0.50% | 100 | 5000 |
| 10 | LIKE_ASSET |
0.05% | 1.00% | 500 | 10000 |
| 60 | BLUE_CHIP |
0.30% | 5.50% | 3000 | 55000 |
| 200 | VOLATILE |
0.50% | 9.00% | 5000 | 90000 |
| 1000 | EXOTIC |
1.00% | 9.90% | 10000 | 99000 |
Fees are in V4 pips:
1,000,000 pips = 100%. The dynamic flag0x800000is never presented as a literal8388608bps; pools are flaggedisDynamicFeeand the per-swap fee is used instead.
Unichain Sepolia and Base Sepolia are already wired. To add another network, edit two places:
These resolve per dataSource.network(), so one codebase serves every chain.
Add a branch for your network (lowercase addresses):
export function getSpryHookAddress(): string {
const network = dataSource.network()
if (network == 'unichain-sepolia') return '0x68ba5f1a761253c7c169f3fde5b715c027814080'
if (network == 'base-sepolia') return '0x43c99d40e2e7fba44435bfc6da57a74d38fd0080'
return SPRY_UNCONFIGURED // 0xff…: an unconfigured network indexes nothing
}
// getSpryRouterAddress() follows the same shape (used only for `viaSpryRouter`).The PoolManager / PositionManager are the canonical V4 deployments per
network. SpryHook is the hook data source (it listens to the SpryFee
event). startBlock should be the SpryHook deploy block (no Spry pool
exists before it). Presets are provided for unichain-sepolia and base-sepolia
(plus a placeholder sepolia):
"unichain-sepolia": {
"PoolManager": { "address": "0x00b0…62ac", "startBlock": 54497329 },
"PositionManager": { "address": "0xf969…d664", "startBlock": 54497329 },
"SpryHook": { "address": "0x68ba…4080", "startBlock": 54497329 }
}
⚠️ The hook address lives in two synced places and must match per network: theSpryHookentry innetworks.json(the data source that listens toSpryFee) and thegetSpryHookAddress()branch insrc/utils/spry.ts(used by the Initialize filter, which runs in the PoolManager context and cannot read the hook data source's address).
The
network:insubgraph.yamlmust be one of the networks defined insrc/utils/chains.ts(the inherited multi-chain pricing config). The provided testnets already exist there.
Regenerate subgraph.yaml for your target network from networks.json before
building or deploying:
yarn generate-subgraph unichain-sepolia # rewrites subgraph.yaml for the named network
# graph-cli's native flag also works: `graph build --network unichain-sepolia`yarn install
yarn codegen # graph codegen: generates ./src/types from schema + ABIs
yarn build # graph codegen && graph build: compiles mappings to WASM
yarn test # matchstick unit tests (see "Testing" below)
# deploy (example: Alchemy / Satsuma hosted)
graph deploy <SUBGRAPH_NAME> \
--node https://subgraphs.alchemy.com/api/subgraphs/deploy \
--ipfs https://ipfs.satsuma.xyz \
--network unichain-sepoliagraph codegen and graph build both pass, and schema.graphql is valid.
Goldsky hosts standard graph-cli subgraphs, so this is the
same subgraph and codebase (no fork, no separate manifest); only the deploy
target differs. One-time setup: install the CLI (curl https://goldsky.com | sh)
and run goldsky login with an API key from the Goldsky dashboard. Then:
yarn deploy:goldsky 1.0.0 unichain-sepolia # retarget Unichain -> spry-subgraph-unichain-sepolia/1.0.0
yarn deploy:goldsky 1.0.0 base-sepolia # retarget Base -> spry-subgraph-base-sepolia/1.0.0That runs scripts/deploy-goldsky.sh, which does
graph codegen + graph build then goldsky subgraph deploy <name>/<version> --path .. Each chain is a separate deployment, named spry-subgraph-<network>
(e.g. spry-subgraph-unichain-sepolia, spry-subgraph-base-sepolia), so per-chain
deployments do not overwrite one another. The network: must be a Goldsky-supported
network (these overlap heavily with The Graph's network slugs).
The diff is intentionally small and reviewable. Everything else (pricing/TVL,
ticks, positions, tokens, day/hour scaffolding, liquidity math) is inherited
unchanged, including src/utils/chains.ts, pricing.ts, token.ts,
tick.ts, and liquidityMath/*.
| File | Why |
|---|---|
src/utils/spry.ts |
Spry constants (hook/router addrs, fee flags) + helpers: tierFromTickSpacing, feePipsToPercent, isDynamicFee, cleanFeePips, zoneName, dispatchCaseName. |
abis/SpryHook.json |
The SpryFee event ABI (the hook's only event), for the SpryHook data source. |
src/mappings/spryFee.ts + spryHook.mapping.ts |
handleSpryFee: records SpryFeeObservation/SpryFeeWindow, zone/case distributions, and the SpryFeePending hand-off. |
src/mappings/donate.ts |
Donate handler (the upstream had none) that records donations on Spry pools. |
tests/handlers/handleInitializeSpry.test.ts, handleSpryFee.test.ts |
Spry filter (non-Spry hook / static fee / bad tickSpacing skipped) + tier; the SpryFee↔Swap join. |
queries.graphql |
The example queries below. |
| File | Change |
|---|---|
schema.graphql |
enum PoolTier/SpryZone/SpryDispatchCase; Pool tier + dynamic-fee stats + zone/case counters + last*; Swap.fee/feePercent/feeAmountUSD/viaSpryRouter + spryFee/cum*/zone/dispatchCase/windowId; new Tier, Donate, SpryFeeObservation, SpryFeeWindow, SpryFeePending entities; dynamic-fee fields on PoolDayData/PoolHourData. Removed EulerSwapHook/ArrakisHook. |
src/mappings/poolManager.ts |
Initialize: filter to hooks == SPRY_HOOK_ADDRESS AND fee == 0x800000 AND a valid Spry tickSpacing; derive tier/baseFeePips/capFeePips; non-sentinel feeTier; init Tier + zone/case counters. Removed the Tempo stable-stable branch. |
src/mappings/swap.ts |
Removed HookSwap/aggregator paths. Source the per-swap fee from the paired SpryFee; maintain pool min/max/last/avg fee stats + feesUSD; roll into PoolDayData/PoolHourData and Tier; attach spryFee/cum*/zone/dispatchCase/windowId and consume the SpryFeePending hand-off. |
src/mappings/modifyLiquidity.ts |
Removed the aggregator-hook TVL branch, leaving the standard V4 TVL path only. |
src/utils/intervalUpdates.ts |
Initialize the new PoolDayData/PoolHourData dynamic-fee accumulators. |
src/mappings/poolManager.mapping.ts |
Export handleDonate; drop handleHookSwap. |
subgraph.yaml / networks.json / scripts/generate-subgraph.ts |
Three data sources: PoolManager (incl. Donate), PositionManager, SpryHook (SpryFee); per-network addresses + start blocks (Unichain Sepolia, Base Sepolia); foreign data sources removed. |
package.json |
Renamed to spry-subgraph. |
tests/handlers/* |
Pools created through the dynamic-fee Spry filter (fixtures use 0x800000); assert tier + dynamic-fee + zone/case fields; SpryFee↔Swap join test. Fixed a pre-existing upstream isExternalLiquidity gap and a non-idempotent TVL assertion. |
src/mappings/{arrakis,euler}.{ts,mapping.ts} and
abis/{AggregatorHook,ArrakisHookFactory,EulerSwapFactory}.json. These index
other protocols' hooks and are irrelevant to Spry.
handleInitialize is the only place that creates a Pool. It creates one only
when all three hold (per the contracts team's spec):
hooks == SPRY_HOOK_ADDRESS AND fee == 0x800000 AND tickSpacing ∈ {1,10,60,200,1000}
The fee == 0x800000 check matters: V4 only applies the hook's fee override when
the pool is dynamic-fee, so a static-fee pool merely pointed at SpryHook is not
a Spry pool. A bad tickSpacing reverts in the hook (InvalidTier), so such
pools are dead. Every other handler (Swap, ModifyLiquidity, Donate,
SpryFee) bails when Pool.load(id) is null, so the entire subgraph is
automatically scoped to Spry pools, with no per-handler address checks needed.
SpryHook emits one SpryFee event per Spry swap, inside beforeSwap,
immediately before V4's own Swap event for the same pool/hop in the same
tx. It carries the hook's internal state, which is otherwise invisible:
event SpryFee(
PoolId indexed id,
int256 cumBefore, // signed block-windowed cumulative, pre-swap
int256 cumAfter, // post-swap (== the pool's persisted signedCum)
uint24 fee, // resolved LP fee in pips (OVERRIDE flag stripped)
uint8 zone, // 0 safe / 1 alert / 2 danger / 3 cap (of cumAfter)
uint8 dispatchCase, // 0 Growth / 1 Unwind / 2 Flip
uint64 windowId // the active window's start block
);Pairing SpryFee ↔ Swap. The Graph fires handlers in (block, logIndex)
order across all data sources, so handleSpryFee always runs immediately
before the matching handleSwap. handleSpryFee records the observation and
writes a tiny SpryFeePending hand-off keyed by pool id; the next handleSwap
on that pool consumes it (and deletes it), attaching the rich fields onto the
Swap and sourcing the per-swap fee from SpryFee.fee. This is exact, handles
multi-hop (one pair per hop), and needs no log-index arithmetic.
Fee source. Swap.fee is taken from the paired SpryFee.fee (the
authoritative LP fee). It equals the V4 Swap event fee while the protocol fee
is 0 (confirmed for Spry pools). If a V4 protocol fee is ever enabled, the LP
analytics stay correct because they use the hook's LP fee; the difference
PoolManager Swap.fee - SpryFee.fee would be the protocol cut.
What lights up. SpryFeeObservation (per swap), SpryFeeWindow (cumulative
trajectory per block-window: cumOpen/cumLast/cumMin/cumMax), zone & dispatch
distributions on Pool and Tier (safe/alert/danger/capCount,
growth/unwind/flipCount), and Pool.lastCum/lastZone/lastDispatchCase.
The tier table (tickSpacing → base/cap fee, zones) is hardcoded: the contracts confirm it's immutable
pureconstants with no setter/upgrade path, so it can't drift. (SpryHook.tierParams(uint8)is alsopureand available viaeth_callif you ever want the full zone bounds, but we don't need it.)
Pool:tier(PoolTier),baseFeePips,capFeePips,isDynamicFee,lastFeePips,minFeePips,maxFeePips,swapCount,sumFeePips,feeWeightedVolumeUSD,avgFeePips(volume-weighted),donates. Plus SpryFee rollups:spryObservationCount, zone counterssafe/alert/danger/capCount, case countersgrowth/unwind/flipCount, andlastCum/lastZone/lastDispatchCase/lastWindowId.feeTiertracks the current fee (tier base fee at init, then the last per-swap fee), never the0x800000sentinel.feesUSDis total LP fees earned.Swap:fee(pips, fromSpryFee),feePercent(fee/10000),feeAmountUSD,viaSpryRouter, plus the paired hook data:spryFee(link),cumBefore,cumAfter,zone,dispatchCase,windowId.Tier: per-tier aggregate keyed by name:poolCount,swapCount,volumeUSD,feesUSD,min/max/avgFeePips, base/cap fees,tickSpacing, zone/case counters.SpryFeeObservation(one per swap):cumBefore/cumAfter,fee/feePercent,zone,dispatchCase,windowId, pool/tier/window/tx links.SpryFeeWindow(per pool perwindowId):cumOpen/cumLast/cumMin/cumMax,swapCount: the block-window cumulative trajectory.PoolDayData/PoolHourData:min/max/lastFeePips,swapCount,sumFeePips,feeWeightedVolumeUSD,avgFeePipsfor the window.Donate: pool, tokens, sender, amounts,amountUSD.LiquidityProvider(one per distinct liquidity-modifying address per pool):pool,address,createdAt*. DrivesPool.liquidityProviderCount. Noteaddressis the immediate caller (typically the V4 PositionManager or a router), not necessarily the end-user LP.
Field hygiene vs
v4-subgraph:Token.poolCountandPool.liquidityProviderCountare now actually maintained (the upstream left them at 0). Dead inherited fields with no V4 meaning were removed:Pool.observationIndex,Pool.collectedFeesToken0/Token1/USD, and the never-computed untracked TVL (Token/PoolManager.totalValueLockedUSDUntracked,PoolManager.totalValueLockedETHUntracked).
See queries.graphql for the runnable versions:
- Top Spry pools by volume:
TopSpryPoolsByVolume - A pool's recent swaps with per-swap dynamic fee:
PoolRecentSwaps - Avg / min / max dynamic fee per pool:
PoolDynamicFeeStats - Volume & avg fee by tier:
VolumeAndAvgFeeByTier - Daily dynamic-fee trend for a pool:
PoolDailyFeeTrend - An LP's positions:
LpPositions - Zone & dispatch-case distribution (per pool & per tier):
ZoneAndCaseDistribution - A pool's cumulative trajectory by block-window:
PoolWindowTrajectory - Recent swaps with the hook's signed cumulative & zone:
PoolRecentSpryObservations
Built against SpryFinance/spry-contracts @ main (solc 0.8.26, v4-core 46c6834,
v4-periphery 9dafaae). The contracts team confirmed, and this subgraph relies
on:
- The hook emits
SpryFee, so its internal state (the signed block-windowed cumulative, the curve zone, the dispatch case) is fully indexed, not proxied. - Protocol fee = 0 on Spry pools, so
Swap.fee == SpryFee.fee(pure LP fee). We still source the LP fee fromSpryFee.fee, so if the V4 admin ever turns on a protocol fee, LP analytics stay correct (and the gap vsPoolManager Swap.feewould be the protocol cut; watchProtocolFeeUpdatedif you need it). - Canonical, unmodified V4 core:
OVERRIDE_FEE_FLAGis stripped before theSwapevent, so the fee is a clean pip value. - Tier table immutable: hardcoded
pureconstants, no setter/upgrade path, so the hardcoded tier→fee table can't drift. - Single, immutable, non-upgradeable hook (no proxy, one per chain), so the hook address is hardcoded per network (no registry needed).
What is intentionally not indexed (by the contracts team's design, not an omission):
- Router attribution is best-effort only.
Swap.senderis the immediate caller (a router), and any V4-aware router/aggregator can swap Spry pools, soviaSpryRouteronly catches the configuredSPRY_ROUTER_ADDRESS. Map known routers to labels off-chain. (An opt-inSpryRoute(id,user,tag)event could be added later for EOA-level attribution.) - Position → pool linkage uses the canonical V4 convention
salt == bytes32(tokenId)(no extra event), exactly like Uniswap's v4-subgraph.
yarn test # graph test -d (Docker + matchstick)
# or, without Docker:
npx graph test # downloads the matchstick binary and runs in-processAll unit tests pass (82/82), including the inherited Uniswap tests, the Spry
filter / tier / dynamic-fee tests, the SpryFee↔Swap join (single, multi-hop,
and window-rollover), the Donate handler, the non-Spry-pool guard (handlers
stay no-op when no Spry pool exists yet), pure spry.ts helper coverage (all
5 tiers, the fee-flag math, and the zone/case enum maps), and multi-swap fee
aggregation. Two of these differ from the pristine upstream base: the test
pool factory sets isExternalLiquidity, and a swap TVL assertion reads
persisted values rather than re-deriving prices through a non-idempotent path.