A sharp fall in DEX pair TVL is worth investigating, but it does not by itself prove a liquidity withdrawal or a rug pull. The USD value can fall because token prices changed, reserves changed, or the set of tracked pools changed. A useful alert reports the observation, checks that it persists, and keeps missing data separate from a genuine zero.
This guide uses the SiftingIO DEX & DeFi API to watch aggregated pair-level TVL, not an individual pool. Get an API key to connect a watcher, or run the synthetic test below without making any request. The example raises a review signal; it is not an execution or risk-management system.
What the TVL endpoint measures#
GET /v1/last/tvl/{chain}/{pair} returns a reference TVL reading for the tracked pools contributing to a pair on one chain. Supported chain slugs include eth, base, arbitrum, bsc and polygon. A pair identifier has the form WETH-USDC; verify that the pair you need is covered.
The REST fields used here are:
| Field | Type | Meaning |
|---|---|---|
chain, pair | String | The requested chain and pair |
usd | Decimal string | Aggregated USD reference valuation |
r0, r1 | Decimal strings | Aggregated reserves of the two tokens |
n | Integer | Contributing pool count |
v | Integer | Schema version |
t | Integer | Observation timestamp in Unix milliseconds |
This is not executable market depth. A large aggregate does not tell you which price ranges, fee tiers or routes are available for a particular trade. A small pool can lose all its liquidity while the wider pair aggregate moves only slightly. The TVL explainer covers the valuation concept; the watcher below handles a narrower operational question.
Define what the alert actually means#
Use this statement for the alert: “The reported USD TVL has remained at least 50% below the chosen baseline across fresh observations spanning three minutes.”
It does not mean “a withdrawal has been confirmed.” Simultaneous declines in both reserves are supporting context, not proof of cause. A lower pool count can reflect a coverage change as well as a market event. Confirm the cause with pool-specific and on-chain evidence when it matters.
For this example:
- The operator supplies a positive baseline from a reviewed period for the same pair and coverage.
- The low state must span a full 180 seconds, with at least four distinct observations.
- A gap over 90 seconds, an invalid response or a failed request resets pending confirmation.
- Duplicate or out-of-order timestamps cannot advance confirmation.
- One alert is emitted until TVL recovers to at least 75% of the fixed baseline.
The baseline does not slide down with the fall. Otherwise an unresolved drop can appear to recover merely because the comparison window has forgotten the old level. Rebase deliberately when the operator decides a new normal is appropriate.
A small, testable state machine#
Save this as tvl_alert.py. It uses Python’s standard library only and makes no network calls. Thresholds are examples to calibrate against your monitoring requirements, not validated trading signals.
from decimal import Decimal, InvalidOperation, localcontext
class TVLAlert:
def __init__(self, chain, pair, baseline_usd):
self.chain, self.pair = chain, pair
self.baseline = Decimal(baseline_usd)
if not self.baseline.is_finite() or self.baseline <= 0:
raise ValueError("Use a positive, reviewed baseline")
self.last_t = None
self.low_since = None
self.low_count = 0
self.alerted = False
def gap(self):
self.low_since, self.low_count = None, 0
def observe(self, body, now_ms):
try:
if body["chain"] != self.chain or body["pair"] != self.pair:
raise ValueError("Wrong series")
if any(not isinstance(body[k], str) or not body[k].strip()
for k in ("usd", "r0", "r1")):
raise ValueError("Expected decimal strings")
usd, r0, r1 = (Decimal(body[k]) for k in ("usd", "r0", "r1"))
if any(not x.is_finite() or x < 0 for x in (usd, r0, r1)):
raise ValueError("Invalid value")
timestamp, pools = body["t"], body["n"]
if type(timestamp) is not int or type(pools) is not int or pools < 1:
raise ValueError("No usable observation")
if not 0 <= now_ms - timestamp <= 90_000:
raise ValueError("Stale or future observation")
except (KeyError, TypeError, ValueError, InvalidOperation):
self.gap()
return {"status": "data_unavailable"}
if self.last_t is not None and timestamp <= self.last_t:
self.gap()
return {"status": "duplicate_or_out_of_order"}
if self.last_t is not None and timestamp - self.last_t > 90_000:
self.gap()
self.last_t = timestamp
with localcontext() as ctx:
ctx.prec = 50
ratio = usd / self.baseline
context = {"usd": str(usd), "baseline_usd": str(self.baseline),
"r0": str(r0), "r1": str(r1), "pools": pools, "t": timestamp}
if self.alerted:
if ratio >= Decimal("0.75"):
self.alerted = False
self.gap()
return {"status": "recovered", **context}
return {"status": "alert_open", **context}
if ratio > Decimal("0.5"):
self.gap()
return {"status": "ok", **context}
if self.low_since is None:
self.low_since = timestamp
self.low_count += 1
if timestamp - self.low_since >= 180_000 and self.low_count >= 4:
self.alerted = True
return {"status": "alert", **context}
return {"status": "confirming", **context}
This deliberately rejects empty strings, null, NaN, negative values and zero contributing pools. They are not evidence of a zero-dollar pair. A valid string "0" with otherwise valid coverage is a numeric observation and still has to pass the same confirmation rule.
Freshness here depends on a synchronized local clock. Future timestamps become data_unavailable rather than being accepted as newer evidence. A real service should monitor clock health and data availability separately.
Prove the three-minute boundary#
Append this synthetic test. It does not fetch a real pair or imply current market conditions:
def fixture(t, usd):
return {"chain": "eth", "pair": "WETH-USDC", "usd": usd,
"r0": "100", "r1": "200000", "n": 4, "v": 1, "t": t}
if __name__ == "__main__":
start = 1_790_769_600_000
watch = TVLAlert("eth", "WETH-USDC", "1000000")
statuses = []
for seconds in (0, 60, 120, 180):
now = start + seconds * 1000
statuses.append(watch.observe(fixture(now, "400000"), now)["status"])
assert statuses == ["confirming", "confirming", "confirming", "alert"]
now = start + 240_000
assert watch.observe(fixture(now, "400000"), now)["status"] == "alert_open"
now = start + 300_000
assert watch.observe(fixture(now, "800000"), now)["status"] == "recovered"
print(statuses)
Three readings at 0, 60 and 120 seconds span two minutes, not three. A burst of four messages in a second also does not qualify. These are the boundary tests to keep if you adapt the code to another language or delivery method.
Connect one REST poller#
Use a separate runner, with requests installed. Set SIFTING_KEY and a reviewed BASELINE_USD in your server’s environment. This runner checks one pair and logs status; replace the alert log with your notification mechanism.
import json
import os
import time
import requests
from tvl_alert import TVLAlert
chain, pair = "eth", "WETH-USDC"
watch = TVLAlert(chain, pair, os.environ["BASELINE_USD"])
headers = {"X-API-Key": os.environ["SIFTING_KEY"]}
while True:
wait_seconds = 60
try:
response = requests.get(
f"https://api.sifting.io/v1/last/tvl/{chain}/{pair}",
headers=headers, timeout=10, allow_redirects=False,
)
if response.status_code in (401, 403):
raise SystemExit("Check credentials and DEX entitlement before restarting")
if response.status_code == 429:
watch.gap()
retry = response.headers.get("Retry-After", "")
if not retry.isdigit():
raise SystemExit("429 without numeric Retry-After; inspect before retrying")
wait_seconds = max(60, int(retry))
print(json.dumps({"status": "rate_limited", "retry_seconds": wait_seconds}))
elif response.status_code != 200:
watch.gap()
print(json.dumps({"status": "data_unavailable", "http": response.status_code}))
else:
result = watch.observe(response.json(), int(time.time() * 1000))
print(json.dumps(result))
except (requests.RequestException, ValueError):
watch.gap()
print(json.dumps({"status": "request_failed"}))
time.sleep(wait_seconds)
No overlapping interval jobs are started. A failed or stale response never enters the confirmation sequence as zero. The REST 429 branch respects the API’s numeric Retry-After; it does not retry once per minute through a monthly-quota block.
Before continuous use, persist the fixed baseline, alert latch and notification identity. This minimal process keeps state in memory; restarting it can generate a fresh notification for an unresolved incident. Add monitoring for a stopped process as well as the data_unavailable states it can emit while running.
Size the monitoring budget#
At an exact one-minute cadence, one pair needs 43,200 REST calls in a 30-day month; three pairs need 129,600. Response time and backoff reduce the actual frequency of this runner. Check the current quota and rate limits before adding pairs.
Polling three pairs every 15 minutes would use 8,640 calls, but it cannot support this three-minute confirmation rule. Slower polling is a different monitoring design, not the same alert at a lower price.
A WebSocket subscription can feed observations into a suitably adapted state machine. Do not mix polling and streaming observations without timestamp deduplication and a documented sampling policy. The code here stays REST-only so the cadence, freshness checks and test results remain easy to inspect.
What to investigate after an alert#
Compare the reserves and pool count with the baseline period, then inspect the underlying pools if a routing or security decision depends on the result. A reserve change, valuation change and coverage change can coexist. The alert is evidence that the reported aggregate fell and stayed lower in your observations, not a diagnosis of why.
That distinction keeps the monitor honest: it draws attention to a meaningful change without presenting pair-level reference data as a per-pool safety guarantee.



