Skip to main content
To detect a liquidity drain (a rug pull, an LP exit, a liquidity migration) programmatically, subscribe to a pool’s reserves over SSE and alert when the pool’s net USD reserve change in a single block exceeds a threshold. A good starting bar: more than 10,000andmorethan2010,000 *and* more than 20% of the pool. Swaps can't trigger this signal, because a swap moves one token up and the other down and nets to roughly zero. Only liquidity actually entering or leaving a pool moves the net total. A detector built this way caught a fresh Base pool losing **97.3% of its liquidity (67k) in one block**, the night it was first switched on. This guide builds that detector in about 70 lines of JavaScript or Python, watching pools on three chains over one connection. No API key, no node, no event-log decoding, no getReserves polling loop.

🛰️ Production Example: LiquidityRadar

The full open-source version of what you’ll build here. A zero-dependency engine, a clone-and-run CLI, and a deployable Cloudflare Worker with a public status page, webhook alerts, and flood protection. Fork it and point it at your own pools. View source →

How detection works

Every block in which a subscribed pool’s reserves change, the stream emits a pool_reserves event with USD values already computed, including total_delta_usd, the signed dollar change of the whole pool in that block. That one field is the entire trick:
What happened on-chaintotal_delta_usdWhy
Swap (any size)≈ $0One token’s reserve goes up, the other goes down. The dollar values cancel.
Liquidity addedStrongly positiveBoth tokens’ reserves rise together.
Liquidity removed / drainedStrongly negativeBoth tokens’ reserves fall together. Nothing cancels it.
A real capture of a 45,930swapintheUSDC/WETH0.0545,930 swap in the USDC/WETH 0.05% pool: one leg `+46,015, the other -$45,930, net total_delta_usd: 84.37`. The same pool’s biggest one-block liquidity moves run six figures with nothing on the other side. A drain cannot disguise itself as trading volume.
Big numbers arrive as strings. The raw token amounts (reserve, delta) and block are JSON strings, because they exceed JavaScript’s Number.MAX_SAFE_INTEGER. Parse them with BigInt if you need them. For drain detection you don’t: the USD fields (total_reserve_usd, total_delta_usd, reserve_usd, delta_usd) are regular numbers, pre-computed server-side.

Step 1: Watch a pool’s reserves live

No signup, no key. This streams real reserve changes for the largest USDC/WETH pool on Ethereum, one event per block in which its reserves changed:
curl -N "https://streaming.dexpaprika.com/sse/reserves?method=pool_reserves&chain=ethereum&address=0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640"
Live capture from production
event: pool_reserves
request_id: 0
data: {"chain":"ethereum","pool_id":"0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640","block":"25286203","previous_block":"25286202","tokens":[{"token_id":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","reserve":"19397067871705","delta":"46015585500","price_usd":0.9999897960291432,"reserve_usd":19396869.94,"delta_usd":46015.11},{"token_id":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","reserve":"39923043997983135635229","delta":"-28370314221473922862","price_usd":1618.97,"reserve_usd":64634288.92,"delta_usd":-45930.74}],"total_reserve_usd":84031158.86,"total_delta_usd":84.37,"timestamp":1781084332,"block_timestamp":1781084327}
The fields the detector cares about:
  • total_reserve_usd: the pool’s current total liquidity in USD
  • total_delta_usd: the signed USD change this block (the swap above nets to 84.37, which is noise)
  • block: where it happened, for receipts
To follow a token across every pool it trades in, use method=token_reserves with a token address instead. Those events are one-sided by nature (a large buy also drops reserves), so reserve the word “drain” for pool-mode alerts.

Step 2: The detector

Watch up to 25 pools on a single connection by POSTing a JSON array. Events route back via request_id, the index of the subscription in your array. Mixing chains in one connection works. The detection rule, two thresholds that scale together:
  • |total_delta_usd| ≥ MIN_USD, which ignores dust
  • |total_delta_usd| / reserve_before ≥ MIN_PCT, which ignores moves the pool barely feels
On a 50Mbluechippool,2050M blue-chip pool, 20% means an eight-figure event. On a 100k day-old memecoin pool, a $20k pull trips it. Same two numbers, self-scaling.
// drain-detector.mjs: alert when a pool loses a big share of its liquidity
// in one block. Node 18+, no dependencies, no API key.

const POOLS = [
  { chain: "ethereum", address: "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", label: "USDC/WETH 0.05%" },
  { chain: "base", address: "0xb2cc224c1c9fee385f8ad6a55b4d94e92359dc59", label: "WETH/USDC (Aerodrome)" },
  { chain: "solana", address: "8sjV1AqBFvFuADBCQHhotaRq5DFFYSjjg1jMyVWMqXvZ", label: "USDT/USDC (Orca)" },
];

const MIN_USD = 10_000; // ignore moves smaller than this
const MIN_PCT = 0.2; // ...and smaller than this share of the pool

async function watch() {
  const res = await fetch("https://streaming.dexpaprika.com/sse/reserves", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(POOLS.map(({ chain, address }) => ({ chain, address, method: "pool_reserves" }))),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

  let buffer = "";
  for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
    buffer += chunk;
    const frames = buffer.split("\n\n");
    buffer = frames.pop(); // keep the partial frame for the next chunk

    for (const frame of frames) {
      const event = frame.match(/^event: (.*)$/m)?.[1];
      const id = frame.match(/^request_id: (.*)$/m)?.[1];
      const data = frame.match(/^data: (.*)$/m)?.[1];
      if (!data) continue;
      if (event === "error") throw new Error(JSON.parse(data).message);
      if (event !== "pool_reserves") continue; // skip pings and warnings

      const update = JSON.parse(data);
      const delta = update.total_delta_usd;
      const before = update.total_reserve_usd - delta; // reserve before this block
      const pct = before > 0 ? delta / before : 0;

      // A swap moves one token up and the other down, so total_delta_usd nets
      // to ~0. Only liquidity entering or leaving the pool moves the total.
      if (Math.abs(delta) < MIN_USD || Math.abs(pct) < MIN_PCT) continue;

      const pool = POOLS[Number(id)] ?? { label: update.pool_id };
      const verb = delta < 0 ? "🚨 DRAIN" : "🟢 ADD";
      console.log(
        `${verb} ${pool.label} on ${update.chain}: ` +
          `${delta < 0 ? "-" : "+"}$${Math.abs(delta).toLocaleString("en-US", { maximumFractionDigits: 0 })} ` +
          `(${(pct * 100).toFixed(1)}% of the pool) at block ${update.block}`,
      );
    }
  }
}

while (true) {
  try {
    console.log("connecting…");
    await watch();
  } catch (err) {
    console.error(String(err));
  }
  await new Promise((r) => setTimeout(r, 5000)); // simple retry; see the production guide
}
Run it (node drain-detector.mjs / python drain_detector.py). At the default thresholds, alerts are rare by design. A 20% single-block move on a real pool is an event. Output from a live run with thresholds lowered for demonstration:
Live run (thresholds lowered to show output)
connecting…
🟢 ADD WETH/USDC (Aerodrome) on base: +$429,397 (5.3% of the pool) at block 47239458
🚨 DRAIN WETH/USDC (Aerodrome) on base: -$329,331 (-3.8% of the pool) at block 47239459
🚨 DRAIN WETH/USDC (Aerodrome) on base: -$100,020 (-1.2% of the pool) at block 47239465
🟢 ADD WETH/USDC (Aerodrome) on base: +$97,078 (1.2% of the pool) at block 47239466
That add/drain oscillation is real and worth understanding. The thresholds section explains it.

Step 3: Tune the thresholds

Major pools host JIT (just-in-time) liquidity bots that add and remove six-figure positions around individual swaps. We’ve measured them moving ±4% of an $8M pool every block, which is exactly the oscillation in the sample output above. Liquidity genuinely moves, so a detector genuinely fires; it’s just market plumbing, not an exit. A 20% bar clears that churn with a 5x margin. If you watch pools with heavy JIT activity and want a lower bar, net the deltas over a few consecutive blocks: JIT cycles cancel out, drains don’t.
For day-old pools in the 20k20k–500k range, the defaults already work: a rug pull on a 70kpoolisa 70k pool is a ~70k negative delta at ~100% of the pool. If you want earlier partial-exit signals, drop MIN_USD to ~$5k and keep MIN_PCT at 20%. On small pools the percentage gate does the real work.
The REST filter endpoint finds rug-candidates programmatically: pools created in the last week with real activity:
curl "https://api.dexpaprika.com/networks/base/pools/filter?created_after=$(($(date +%s) - 604800))&txns_24h_min=50&volume_24h_min=10000&sort_by=created_at&sort_dir=desc&limit=25"
Feed the resulting pool IDs straight into POOLS. One connection covers 25 of them.

Step 4: Route alerts anywhere

Replace console.log with a webhook call and the detector becomes a bot. Discord, for example:
await fetch(process.env.WEBHOOK_URL, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ content: alertText }),
});
Before pointing this at a channel people read, add flood protection: deduplicate on pool_id + block, hold a per-pool cooldown, and cap sends per hour, because a multi-block drain fires once per block otherwise. LiquidityRadar implements that gate, plus reconnection with backoff, a persistent status page, and one-command Cloudflare deployment, if you’d rather fork than build.

Limits and errors worth knowing

  • 25 subscriptions per POST connection, 10 concurrent streams per IP. Chunk bigger watchlists across connections.
  • One invalid entry rejects the whole multiplexed connection. The stream sends an error event naming the bad asset ("pool not found: …"), then closes. Validate addresses before subscribing.
  • error events are not all equal: subscription problems (asset not found, unsupported chain) are permanent: fix the input, don’t retry. Capacity problems (ip stream limit exceeded) clear on their own, so retry those with backoff.
  • A ping arrives every ~15s; if nothing arrives for 30s+, reconnect. See error handling for the full catalog.

FAQ

Not by swapping: swaps net to ~zero in total_delta_usd regardless of size. An exit could in principle drip liquidity out in many small blocks below your thresholds, which is why percentage-of-pool matters more than absolute USD: 100 blocks of 1% exits still cross a 20% net threshold if you accumulate deltas over a window.
Polling misses everything between polls. A pool can drain in one block, and a 30-second loop finds out after the money is gone. Decoding Sync/Mint/Burn logs over a WebSocket node connection works but costs a node provider subscription and per-DEX decoding logic. The reserve stream pushes per-block deltas with USD values pre-computed, for every major DEX shape (Uniswap v2/v3/v4, Curve, Aerodrome, Raydium, Orca…), with no key.
Free, no API key, no credit card. The published limits are the catch: 25 subscriptions per connection, 10 streams per IP.
The same code in this guide watches Ethereum, Base, and Solana on one connection. Any chain DexPaprika indexes works, see networks.

Next steps

Reserve Streaming Overview

The full feature guide: both subscription methods, payload schemas, limits.

Streaming Quick Start

Prices and reserves from zero in 10 minutes.

Multi-Pool API Reference

The POST /sse/reserves endpoint in full detail.

LiquidityRadar on GitHub

The production version: deployable worker, status page, alert gate.

Get support

Join Discord

Connect with our community and get real-time support.

Give Feedback

Share your experience and help us improve.