A concentrated-liquidity position only earns fees while the pool’s price sits inside its range, and nothing tells you when it leaves. This tutorial builds a small script that watches a position and sends you this on Telegram the moment it happens:
Position #1400520 is out of range. Pool tick -274347 is outside [-274466, -274386), so it has stopped earning fees.TL;DR
Read positions(tokenId) on the NonfungiblePositionManager and slot0().tick on the pool in a single evmquery request, compare the tick with tickLower and tickUpper, and message Telegram only when the answer changes. A state.json file remembers the last answer between cron runs.
Prerequisites
- Node.js 22.18 or newer (24 is what I used). Both run
.tsfiles directly, so there is no build step. - An evmquery API key from app.evmquery.com.
- A Telegram bot token from @BotFather and your chat id. Send your bot any message, then open
https://api.telegram.org/bot<token>/getUpdatesand copymessage.chat.id. - The token id of a Uniswap V3 position on Ethereum and the address of its pool.
mkdir lp-alert && cd lp-alertnpm init -ynpm pkg set type=modulenpm install @evmquery/sdkPut the secrets in a .env file next to the script, as plain KEY=value lines:
EVMQUERY_API_KEY=...TELEGRAM_BOT_TOKEN=...TELEGRAM_CHAT_ID=...type=module is needed because the script uses top-level await. If you want to read the same data without evmquery, the raw route is viem plus Multicall3, which the pool data migration post walks through.
What “out of range” means on-chain
A V3 position is an NFT. positions(tokenId) on the NonfungiblePositionManager (0xC36442b4a4522E871399CD717aBDD847Ab11FE88 on Ethereum) returns token0, token1, fee, tickLower, tickUpper and liquidity, among others. The pool’s slot0() returns the current tick. The position is in range when tickLower <= tick < tickUpper, and the upper bound is exclusive. At exactly tickUpper the position is already fully converted to token1.
You do not need to turn ticks into prices to alert on this. Comparing integers is enough. If you also want the price at the edges of the range, the pool data guide has the sqrtPriceX96 and tick maths, and I will not repeat it here.
Reading the position and the pool in one request
The position manager and the pool are two contracts, so the request declares both by name and passes the token id as a context variable:
const expression = ` cel.bind(p, npm.positions(tokenId), cel.bind(tick, pool.slot0().tick, { "poolMatches": dyn(pool.token0() == p.token0 && pool.token1() == p.token1 && pool.fee() == p.fee), "inRange": dyn(tick >= p.tickLower && tick < p.tickUpper), "liquidity": dyn(p.liquidity), "tick": dyn(tick), "tickLower": dyn(p.tickLower), "tickUpper": dyn(p.tickUpper) }))`;positions() returns a struct with twelve fields, and cel.bind(p, ...) calls it once and lets you read fields off p. Without the bind, every p.tickLower would be a separate call. The second bind does the same for the tick.
Wrap each value in dyn() so one map can carry booleans and integers together. In the response, integers come back as objects with a string value and booleans come back bare.
{ "poolMatches": true, "inRange": true, "liquidity": { "value": "20739915444167" }, "tick": { "value": "199656" }, "tickLower": { "value": "198750" }, "tickUpper": { "value": "200780" }}That was position 1400519 in the USDC/WETH 0.05% pool when I ran it. The response meta reported five contract calls in one round, so the whole check costs one HTTP request.
Checking that the pool matches the position
The pool address is something you supply, and a wrong one fails quietly: slot0().tick still returns a perfectly valid tick, just for another market, and you get alerts about the wrong price. That is what poolMatches guards against. It compares the pool’s token0(), token1() and fee() with the same fields on the position.
I tried it on purpose by pairing position 1400519 with the USDC/WETH 0.3% pool. Same tokens, different fee, and poolMatches came back false. The script throws in that case instead of alerting.
To find the pool address for a position you already hold, ask the Uniswap V3 factory (0x1F98431c8aD98523631AE4a59f267346ea31F984) with the position’s own values: factory.getPool(p.token0, p.token1, p.fee). For 1400519 that returned 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640. Do it once by hand, then hardcode the result so the watcher stays a two-contract read.
The whole script
import { readFileSync, writeFileSync } from "node:fs";import { createEvmqueryClient, isEvmqueryError } from "@evmquery/sdk";
const evmquery = createEvmqueryClient({ apiKey: process.env.EVMQUERY_API_KEY });
const POSITION_MANAGER = "0xC36442b4a4522E871399CD717aBDD847Ab11FE88";const WATCHED = [ // USDC/WETH 0.05% on Ethereum { tokenId: 1400519, pool: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" },];const STATE_FILE = new URL("./state.json", import.meta.url);
type Wrapped = { value: string };type Reading = { poolMatches: boolean; inRange: boolean; liquidity: Wrapped; tick: Wrapped; tickLower: Wrapped; tickUpper: Wrapped;};type State = Record<string, "ok" | "alert" | "gone">;
const expression = ` cel.bind(p, npm.positions(tokenId), cel.bind(tick, pool.slot0().tick, { "poolMatches": dyn(pool.token0() == p.token0 && pool.token1() == p.token1 && pool.fee() == p.fee), "inRange": dyn(tick >= p.tickLower && tick < p.tickUpper), "liquidity": dyn(p.liquidity), "tick": dyn(tick), "tickLower": dyn(p.tickLower), "tickUpper": dyn(p.tickUpper) }))`;
async function sendTelegram(text: string): Promise<void> { const res = await fetch( `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id: process.env.TELEGRAM_CHAT_ID, text }), signal: AbortSignal.timeout(10_000), }, ); if (!res.ok) throw new Error(`Telegram returned ${res.status}`);}
async function readPosition(tokenId: number, pool: string): Promise<Reading> { const { data } = await evmquery.query({ throwOnError: true, body: { chain: "evm_ethereum", schema: { contracts: { npm: { address: POSITION_MANAGER }, pool: { address: pool } }, context: { tokenId: "sol_int" }, }, context: { tokenId }, expression, }, }); return data.result.value as Reading;}
function saveState(): void { writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));}
let state: State = {};try { state = JSON.parse(readFileSync(STATE_FILE, "utf8"));} catch { // first run}
for (const { tokenId, pool } of WATCHED) { const key = String(tokenId); if (state[key] === "gone") continue;
let reading: Reading; try { reading = await readPosition(tokenId, pool); } catch (err) { if (isEvmqueryError(err) && JSON.stringify(err.body).includes("Invalid token ID")) { await sendTelegram(`Position #${tokenId} no longer exists, it was burned. Remove it from WATCHED.`); state[key] = "gone"; saveState(); continue; } throw err; }
if (!reading.poolMatches) { throw new Error(`Pool ${pool} does not match the tokens and fee of position #${tokenId}`); } if (reading.liquidity.value === "0") continue; // closed position, nothing earning fees
const previous = state[key] ?? "ok"; const current = reading.inRange ? "ok" : "alert"; const range = `[${reading.tickLower.value}, ${reading.tickUpper.value})`;
if (previous === "ok" && current === "alert") { await sendTelegram(`Position #${tokenId} is out of range. Pool tick ${reading.tick.value} is outside ${range}, so it has stopped earning fees.`); } else if (previous === "alert" && current === "ok") { await sendTelegram(`Position #${tokenId} is back in range. Pool tick ${reading.tick.value} is inside ${range}.`); } state[key] = current; saveState();}context: { tokenId } declares tokenId as sol_int in the schema and passes it as a JSON number. Token ids are far below 2^53, so a plain number is safe.
throwOnError: true makes the SDK throw on any non-2xx response, so after the await you can use data directly.
Each position is handled on its own: the message goes out first and the state is written only after it was sent. If Telegram fails or a later position throws, the positions already handled keep their saved state, and the failed one is retried on the next run.
State has three values. ok and alert track the range, and a missing entry counts as ok. That makes the first run silent for a healthy position and loud for one that is already out of range, so a position that has been drifting for days gets flagged as soon as you add it. gone is explained below.
Liquidity of zero is skipped. A position you have fully withdrawn but not burned still exists and still has a range, but there is nothing to protect.
Burned token ids
Burning a position deletes its data, and positions() then reverts with Invalid token ID. The error body carries the revert reason, so the script matches on it and sends one last message. Setting the state to gone stops it from retrying every five minutes.
I tested the path with a token id that was never minted, since a burned id behaves the same way:
Position #999999999 no longer exists, it was burned. Remove it from WATCHED.While looking for a live position to test with I noticed that the position manager’s totalSupply() was about 1.25 million, yet the newest token ids were around 1.4 million. The difference is burned positions. Token ids are never reused, so do not assume the id range is dense.
Running it and what the output looks like
The script is silent when nothing changes, so the first thing to check is the state file. For 1400519, which was in range:
node --env-file=.env alert.tscat state.json{ "1400519": "ok"}I then edited state.json to say "alert" and ran it again, to see the recovery message:
Position #1400519 is back in range. Pool tick 199643 is inside [198750, 200780).For the out-of-range case I used position 1400520, which had liquidity and sat just above its upper tick. It produced the message at the top of this post and wrote "1400520": "alert". A second run sent nothing, which is the point of keeping state. These ticks were live when this was written and will have moved by the time you read it.
Scheduling it with cron
crontab -e*/5 * * * * cd /home/me/lp-alert && /usr/local/bin/node --env-file=.env alert.ts >> alert.log 2>&1Cron has no PATH or shell profile, so use the absolute path to node (which node prints it). The cd makes .env and alert.log resolve; the script finds state.json relative to its own location. Any thrown error, such as a pool mismatch, ends up in alert.log.
Five minutes is generous. A V3 position moves in and out of range on price swings, and ranges that are tight enough to flap between runs will send you a pair of messages each time. If that gets noisy, widen the interval or add a margin so the alert only fires when the tick is a few spacings beyond the edge.
To watch more positions, add entries to WATCHED. Each one is its own request, and the same pattern fits any other automated monitoring job.
Or skip the cron: run it as a monitor
The range check also fits an evmquery monitor, which replaces the crontab and state.json. Fold the check into one bool and pass the token id as a context value:
cel.bind(p, npm.positions(tokenId), cel.bind(tick, pool.slot0().tick, tick < p.tickLower || tick >= p.tickUpper))Use a condition trigger. It fires when the position leaves its range and stays quiet while it remains out, then fires again after it has returned and left again. Confirm the pool with the poolMatches read above before you save it. evmquery runs it on a schedule and sends an email or an HTTPS webhook, retries failed deliveries, and pauses the monitor with an alert after repeated errors.
Next steps
- Uniswap V3 pool data via REST for converting
sqrtPriceX96and ticks into prices. - Uniswap V3 pool data in raw viem for the hand-rolled equivalent of the read step.
- Blockchain monitoring in Python for the same polling pattern with Slack.
- Contract Inspector to browse the position manager’s methods before writing expressions.
- Monitors to run the out-of-range condition on a schedule and alert by email or webhook.


