> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dexpaprika.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Detect Liquidity Drains in Real Time

> Build a rug-pull detector that alerts the block liquidity leaves a DEX pool. Free streaming API: no node, no log decoding, no getReserves polling. ~70 lines.

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,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.

<Card title="🛰️ Production Example: LiquidityRadar" icon="satellite-dish" href="https://github.com/coinpaprika/liquidity-radar" color="#10B981">
  **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 →](https://github.com/coinpaprika/liquidity-radar)
</Card>

## 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-chain          | `total_delta_usd` | Why                                                                         |
| ------------------------------- | ----------------- | --------------------------------------------------------------------------- |
| Swap (any size)                 | ≈ \$0             | One token's reserve goes up, the other goes down. The dollar values cancel. |
| Liquidity added                 | Strongly positive | Both tokens' reserves rise together.                                        |
| Liquidity removed / **drained** | Strongly negative | Both tokens' reserves fall together. Nothing cancels it.                    |

A real capture of a $45,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.

<Warning>
  **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.
</Warning>

## 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:

```bash theme={null}
curl -N "https://streaming.dexpaprika.com/sse/reserves?method=pool_reserves&chain=ethereum&address=0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640"
```

```text Live capture from production theme={null}
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

<Tip>
  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.
</Tip>

## 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 $50M 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.

<CodeGroup>
  ```javascript drain-detector.mjs theme={null}
  // 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
  }
  ```

  ```python drain_detector.py theme={null}
  # drain_detector.py: alert when a pool loses a big share of its liquidity
  # in one block. Python 3.8+, `pip install requests`, no API key.

  import json
  import time

  import requests

  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)"},
  ]

  MIN_USD = 10_000  # ignore moves smaller than this
  MIN_PCT = 0.20    # ...and smaller than this share of the pool


  def watch():
      response = requests.post(
          "https://streaming.dexpaprika.com/sse/reserves",
          json=[{"chain": p["chain"], "address": p["address"], "method": "pool_reserves"} for p in POOLS],
          stream=True,
          timeout=60,
      )
      response.raise_for_status()

      event_type, request_id = None, None
      for raw_line in response.iter_lines(decode_unicode=True):
          line = raw_line or ""
          if line.startswith("event: "):
              event_type = line[7:]
          elif line.startswith("request_id: "):
              request_id = line[12:]
          elif line.startswith("data: "):
              if event_type == "error":
                  raise RuntimeError(json.loads(line[6:])["message"])
              if event_type != "pool_reserves":  # skip pings and warnings
                  continue

              update = json.loads(line[6:])
              delta = update["total_delta_usd"]
              before = update["total_reserve_usd"] - delta  # reserve before this block
              pct = delta / before if before > 0 else 0

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

              pool = POOLS[int(request_id)] if request_id and request_id.isdigit() else {"label": update["pool_id"]}
              verb = "🚨 DRAIN" if delta < 0 else "🟢 ADD"
              print(
                  f"{verb} {pool['label']} on {update['chain']}: "
                  f"{'-' if delta < 0 else '+'}${abs(delta):,.0f} "
                  f"({pct * 100:.1f}% of the pool) at block {update['block']}"
              )


  while True:
      try:
          print("connecting…")
          watch()
      except Exception as err:  # noqa: BLE001, simple retry; see the production guide
          print(err)
      time.sleep(5)
  ```
</CodeGroup>

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:

```text Live run (thresholds lowered to show output) theme={null}
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

<AccordionGroup>
  <Accordion title="Why 20%? Because liquidity bots churn below it">
    Major pools host [JIT (just-in-time) liquidity](https://dexpaprika.com/glossary/jit-liquidity-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.
  </Accordion>

  <Accordion title="Watching fresh, small pools (where rugs actually happen)">
    For day-old pools in the $20k–$500k range, the defaults already work: a [rug pull](https://dexpaprika.com/glossary/rug-pull) on a $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.
  </Accordion>

  <Accordion title="Finding pools worth watching">
    The REST pool search endpoint finds rug-candidates programmatically: pools created in the last week with real activity:

    ```bash theme={null}
    curl "https://api.dexpaprika.com/networks/base/pools/search?created_after=$(($(date +%s) - 604800))&txns_24h_min=50&volume_usd_24h_min=10000&order_by=created_at&sort=desc&limit=25"
    ```

    Feed the resulting pool IDs straight into `POOLS`. One connection covers 25 of them.
  </Accordion>
</AccordionGroup>

## Step 4: Route alerts anywhere

Replace `console.log` with a webhook call and the detector becomes a bot. Discord, for example:

```javascript theme={null}
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](https://github.com/coinpaprika/liquidity-radar) 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](/knowledge-base/error-handling) for the full catalog.

## FAQ

<AccordionGroup>
  <Accordion title="Can a rug pull hide from this detector?">
    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.
  </Accordion>

  <Accordion title="Why SSE instead of polling getReserves or listening to Sync events?">
    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.
  </Accordion>

  <Accordion title="Is this free? What's the catch?">
    Free, no API key, no credit card. The published limits are the catch: 25 subscriptions per connection, 10 streams per IP.
  </Accordion>

  <Accordion title="Does it work on Solana / Base / my chain?">
    The same code in this guide watches Ethereum, Base, and Solana on one connection. Any chain DexPaprika indexes works, see [networks](https://api.dexpaprika.com/networks).
  </Accordion>
</AccordionGroup>

<script type="application/ld+json">
  {JSON.stringify({
    "@context": "https://schema.org",
    "@type": "FAQPage",
    "mainEntity": [
      {
        "@type": "Question",
        "name": "How do I detect a rug pull or liquidity drain programmatically?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "Subscribe to a pool's reserves over SSE (streaming.dexpaprika.com/sse/reserves, free, no API key) and alert when the pool's net USD reserve change in one block (total_delta_usd) exceeds both an absolute threshold (e.g. $10,000) and a share of the pool (e.g. 20%). Swaps net to ~0 in this field; only liquidity entering or leaving moves it."
        }
      },
      {
        "@type": "Question",
        "name": "Why can't a swap trigger a false liquidity-drain alert?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "A swap raises one token's reserve and lowers the other's, so the USD-denominated changes cancel: total_delta_usd stays near zero. Removing liquidity lowers both reserves at once, producing a strongly negative total that cannot be disguised as trading."
        }
      },
      {
        "@type": "Question",
        "name": "How many pools can one connection watch?",
        "acceptedAnswer": {
          "@type": "Answer",
          "text": "Up to 25 subscriptions per POST connection (pools and tokens, chains can be mixed), with a limit of 10 concurrent streams per IP. Events route back via request_id, the index of each subscription in the POSTed array."
        }
      }
    ]
    })}
</script>

## Next steps

<CardGroup cols={2}>
  <Card title="Reserve Streaming Overview" icon="water" href="/streaming/reserves-streaming">
    The full feature guide: both subscription methods, payload schemas, limits.
  </Card>

  <Card title="Streaming Quick Start" icon="bolt" href="/streaming/tutorials/quick-start">
    Prices and reserves from zero in 10 minutes.
  </Card>

  <Card title="Multi-Pool API Reference" icon="layer-group" href="/streaming/subscribe-to-multiple-reserves-streaming">
    The POST /sse/reserves endpoint in full detail.
  </Card>

  <Card title="LiquidityRadar on GitHub" icon="github" href="https://github.com/coinpaprika/liquidity-radar">
    The production version: deployable worker, status page, alert gate.
  </Card>
</CardGroup>

***

## Get support

<CardGroup cols={2}>
  <Card title="Join Discord" icon="discord" href="https://discord.gg/DhJge5TUGM">
    Connect with our community and get real-time support.
  </Card>

  <Card title="Give Feedback" icon="message" href="mailto:support@coinpaprika.com">
    Share your experience and help us improve.
  </Card>
</CardGroup>
