DEV Community

Cover image for Two in three 'liquidity pulled' alerts are the same wallet putting it back
Edy Cu
Edy Cu

Posted on

Two in three 'liquidity pulled' alerts are the same wallet putting it back

A treasury bot pings: LP removed, −$21,330,275, UNI/WBTC. Somebody pulls up a chart, somebody asks in the group chat whether the market maker left, and for the next hour the answer is a guess.

The answer was sitting in the same API, one query away. 132 seconds later the same wallet put 99.8% of that money — the same 1,962,475.5391248302 UNI, to the last decimal — into UNI/WETH, one pool over. The alert was true. The panic was not.

So I stopped looking at one removal and followed 335 of them. Two in three "liquidity pulled" alerts were the same wallet putting it back within six hours. This post is about that number, the one API parameter that produces it, and the two kinds of row that almost made it wrong.

The alert everyone fires, and the row nobody reads

CoinMarketCap's DEX API has /v1/dex/liquidity-change/list: every add and remove on a token's pools, with a USD value (tu), a side (tp), a timestamp, and — the part that matters — the wallet that did it, m. Filter it with minVolume=100000, sort by tu, and you have an "LP removed" alert bot. That is the obvious product, and it stops at the first row.

The endpoint also accepts maker= as a server-side filter. One keyless call returns everything one wallet did across every pool of the token. That is the whole trick — the second row was never hidden, it was just never joined:

def follow_maker(
    client, platform, address, maker, *, t0_ms, back_h=W_BACK_H, fwd_h=W_FWD_H, pages=FOLLOW_PAGES
):
    """THE JOIN. Every liquidity event by `maker` on `address`, inside t0 -+ the window."""
    lo = t0_ms - int(back_h * 3600_000)
    rows, meta = walk(
        client,
        {"platform": platform, "address": address, "maker": maker},
        pages,
        until=lambda page: min(ts_ms(r) for r in page) < lo,
    )
    return window_rows(rows, meta, t0_ms=t0_ms, back_h=back_h, fwd_h=fwd_h)
Enter fullscreen mode Exit fullscreen mode

The walk() with a cursor is there because startTime is plan-gated on the keyless tier (HTTP 403, error 1013) while endTime is not, so a ±6 h window has to be paged back to with lastId and cut client-side on ts.

Here are the two rows from the live run, verbatim fields:

remove  ts=1788321551000 tu=-21330274.564875204 m=0x4f0aa5900b8292273b2f9a178d5468f8048bb9a9 en=Ring Exchange (Ethereum) a0=-1962475.5391248302
add     ts=1788321683000 tu=21287254.934237212  m=0x4f0aa5900b8292273b2f9a178d5468f8048bb9a9 en=Ring Exchange (Ethereum) a0=1962475.5391248302
        21,287,254.93 ÷ 21,330,274.56 = 0.9980
Enter fullscreen mode Exit fullscreen mode

Same maker, same token amount, a different pair. That run made 14 calls, all 200, 0 credits, in 50.6 s, and every response body is embedded in the receipt under the sha256 the trace prints.

Turning two rows into a verdict

Once you have the wallet's rows inside the window, the classification is arithmetic on tu — no model produces a number. The adds are split into same pool and other pools, and the shares decide:

if same_share >= FULL and same_share >= other_share:
    kind = "REBALANCE"
elif other_share >= FULL:
    kind = "MIGRATION"
    if dest and dest["pubAt_ms"] and dest["pubAt_ms"] > ts_ms(removal):
        kind = "CONSOLIDATION"
elif total_share >= PARTIAL_MIN:
    kind = "PARTIAL"
elif complete:
    kind = "EXIT"
else:
    kind = "INCOMPLETE"
Enter fullscreen mode Exit fullscreen mode

FULL is 0.70 and PARTIAL_MIN is 0.10. The last two lines carry the rule I care most about: an EXIT is only allowed when every follow completed. If a page was throttled or the walk never reached the start of the window, the answer is INCOMPLETE, never "they left". Calling a rebalance an exit is the exact false alarm this exists to kill, so the tool is not allowed to produce one from missing data.

The same follow runs on the asset's other EVM chains too: /v1/dex/search and /v2/cryptocurrency/info resolve the same CoinMarketCap asset to its contract on each chain (13 for UNI), and a chain is followed only if the asset has a market there. /v4/dex/pairs/quotes/latest then confirms the destination pool actually holds the money now.

The number

Following one removal proves the mechanism. To measure how often the alert is the wrong headline, I ran the same-chain follow over every non-JIT removal ≥ $100k on an 11-token, 3-chain watchlist — 335 removals, 352 keyless calls, 0 credits, about 20 minutes of wall clock:

Outcome of a "≥ $100k removed" alert Count Share
Rebalance — ≥ 70% back into the same pool 194 57.9%
Migration — ≥ 70% into a different pool 19 5.7%
Partial — 10–70% came back 10 3.0%
No same-wallet re-add within ±6 h 112 33.4%

223 of 335 — 66.6% — came back. The last row is deliberately not labelled "exits": the base-rate sweep follows the same chain only, so 33.4% is an upper bound on real exits, not a count of them. The receipt is docs/proof/base_rate.json, and every row in it can be re-derived.

Two kinds of row that nearly broke it

The number above only holds because two kinds of row are refused before anything is counted. Both came from running against the live API, not from the docs.

A removal worth 10⁴² dollars. On 2026-09-19, thirteen rows for the real CAKE contract on an unlabeled BSC venue carried tu = -1e+42 — and a0 = -1e+42 CAKE, trillions of times the supply. They pass minVolume, and in an 11-token sweep they out-ranked every real removal. Sort by tu, which is the obvious thing to do, and a scam pool's overflow is the top of every list. The guard is one constant and one function:

# A single liquidity event above this is not a removal, it is a price-feed artefact: live
# 2026-09-19, 13 CAKE rows on an unlabeled BSC venue carried tu = -1e42 (and a0 = -1e42 CAKE,
# trillions of times the supply) and out-ranked every real removal on the watchlist.
SANE_USD = 10_000_000_000.0

def plausible(row):
    """False for a row whose USD value no market could have produced (see SANE_USD)."""
    return usd(row) <= SANE_USD
Enter fullscreen mode Exit fullscreen mode

Removals that are really one swap. Just-in-time liquidity — a searcher adds liquidity for a single swap and pulls it in the same transaction. 85 of 100 PEPE rows were JIT pairs; so were 28 AAVE, 27 DAI and 31 CAKE transactions in 100 rows. Every one of them is a "removal" to a naive consumer. Nothing in the row flags it, but both sides share a txn:

def jit_txns(rows):
    """Transactions that both add and remove — just-in-time liquidity, not an event."""
    sides = {}
    for r in rows:
        sides.setdefault(r.get("txn"), set()).add(r.get("tp"))
    return {t for t, s in sides.items() if {"add", "remove"} <= s}
Enter fullscreen mode Exit fullscreen mode

Neither guard is clever. Both are the kind of thing you only write after the live data surprises you, which is the argument for building against the real API from the first hour instead of against fixtures.

Honest limits

  • Pool identity is venue + pair, not pool address. Rows never carry the pool contract, so three Uniswap v3 fee tiers of one pair collapse into one identity. The card says so.
  • The window is ±6 h. A wallet that comes back a day later reads as "no re-add" for that window.
  • A wallet is not an entity. Liquidity moved through a second wallet is not followed.
  • A wallet that cycles more than once in the window can show a share over 100%; the adds are attributed to the whole window, and the output discloses the other removals.
  • The anonymous tier throttles per IP, reported as 429/1022, 429/1011 or 500, with no Retry-After. The client backs off 15/30/60 s and records the retry in the receipt; the hosted page leads with the dated receipt for that reason.

The whole thing is one stdlib Python file on the default path, an unattended watch loop, and a three-tool MCP server (claude mcp add forwarding -- python3 $PWD/scripts/mcp_server.py). 324 offline tests, 100% statement and branch coverage, gated in CI.

Run it yourself — no key, no signup:

git clone https://github.com/edycutjong/forwarding.git && cd forwarding
python3 scripts/forwarding.py investigate --platform ethereum --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984
Enter fullscreen mode Exit fullscreen mode

The removal it picks comes from the market, so yours may differ from the one above. The live page is at forwarding.edycu.dev, a 2:54 demo is on YouTube, and the twelve dated things the API did that surprised me are in FEEDBACK.md.

If you run an alert on "liquidity removed", query the same endpoint once more with maker= before you send it.

Top comments (0)