---
name: rugscan-3
description: Continuation of [[rugscan-2]] from 9 Sep 2026. Patches 8 and 9 - the daily watchlist now reads Ben's live holdings instead of a hand-kept list, and the Blockscout WAF header fix that had silently zeroed holder counts across five droplet services. Read with [[rugscan]] and [[rugscan-2]] before touching any detector.
sources: [cowork]
aliases: [rugscan part 3, rug-scan-3, holdings-watchlist, blockscout-waf]
---

# rugscan, part 3 (from 9 Sep 2026)

Same box: **/opt/rugscan** on kookwatch (159.223.88.168), key `~/kookwatch/id_kookwatch` on the Mac.

## THE TRIGGER
- [stated] Ben pasted a rugscan Telegram alert covering CHIPS and PAIR and asked: **"why are you doing rugscans on stuff that i don't hold?"** Then: "run it and fix it."
- He was right. `rugscan.service` (systemd timer, 01:00 UTC daily) ran `--watchlist`, and `--watchlist` read `config.json["watchlist"]`, a **hand-kept list of 4 coins: DELTA, OPTIMUS, CHIPS, PAIR**. He holds none of them. A hand-kept list has no mechanism that keeps it equal to the book, so it drifts by construction.

## PATCH 8 - the watchlist now reads the POSITION, not a list
- New `holdings()` in rugscan.py. Reads the wallets in `watch.json`, pulls Blockscout token-balances, **confirms every balance on chain with `balanceOf`** (a stale index must never invent or keep a position), prices each one, and returns only what clears a floor. `HOLD_MIN_USD` env `RS_HOLD_MIN`, default **$25**.
- `--watchlist` now scans holdings. The old manual list still lives in config.json and is only scanned with **`--manual`**. Nothing was deleted, so it is reversible.
- **On a total lookup failure it falls back to the manual list AND prefixes the Telegram message with "⚠️ holdings lookup failed".** A silent fallback would have recreated the exact bug.

### PATCH 9 - v1 of holdings() returned 54 tokens, which was WORSE than the 4 it replaced
- Blockscout's `exchange_rate` field is **null on this chain**, so the v1 dust floor never fired once.
- Fixed by pricing from **DexScreener's deepest pool** (`/latest/dex/tokens/{a,b,...}`, 25 per call, deepest pool wins so a fake pool cannot set the price).
- **Measured on the fomo wallet `0x01c2E8cA848486166e3D08fa987d69b65df0D00F`: 57 token balances, exactly TWO are positions.** ICOIN `0x5d6ef090a1461b11c9427ac319260122d1c61e18` ~$9,165 and PRM `0xf24f8f6b08fe87cf062e833a732ad7f636064bc8` ~$7,599. The next largest is the SNOWBALL copycat at **$3.74**; 42 tokens have no live pool at all. **An RH trench wallet is ~95% airdrop and exit dust by token count.** Any "what do I hold" question on this chain must value the balances or it will return nonsense.
- Live run verified: `holdings: 57 balances, 13 dust under $25, 42 unpriced -> 2 positions`, then `sent 2 of 2`.

## PATCH 10 - THE ALERT RULE. DANGER ONLY, AND A SCAM HE OWNS NOW SENDS.
- [stated] Ben, same session: **"I only need new coins that I've touched and bought. I don't need a repeated scan of something you already told me is not a scam. If you think something is a scam and I'm still holding it, you can keep sending those alerts, but not if it's not."**
- Three changes to the `--watchlist` path:
  1. **Only `SCAM` and `AVOID` are delivered.** `CLEAN` and `CAUTION` never go out at all, changed or not. The run prints `quiet: N clean/caution token(s), not alerted`.
  2. **`--always` removed from `/etc/systemd/system/rugscan.service`.** It was forcing a full resend every single day, which is what made a clean verdict repeat.
  3. **THE PATCH-2 SCAM SUPPRESSION IS REVERSED FOR TOKENS HE HOLDS.** That rule was written on 7 Sep when this list was discovery candidates; the list is now his own book, so silence on a coin he owns is the dangerous choice. A `SCAM` he does NOT hold (only reachable via `--manual`) is still logged to blocked.jsonl and not sent. The stale 7 Sep comment in the source was rewritten so a later session cannot read it as still current.
- **Verified by forcing all five cases through the real `main()` with `tg()` and `line()` stubbed** (the sandbox method of [[rugscan-2]] doctrine 12): HELDCLEAN silent, HELDCAUTION silent, HELDAVOID **SENT**, HELDSCAM **SENT**, MANUALSCAM not sent. `sent 2 of 4`.
- **New buys are rugwatch's job, not this one.** rugwatch polls his wallets every 8s by direct RPC log poll and already alerts on anything he buys, with the "⛔⛔ YOU JUST BOUGHT A SCAM ⛔⛔" prefix. Nothing about that path changed.

### GAP FLAGGED, NOT FIXED
- **`watch.json` holds only ONE wallet.** [[ben-positions]] records STONKBROKER and SESTRI on Robinhood Chain, and neither is in this wallet, so a second RH wallet exists that rugwatch and rugscan are both blind to. Ben needs to supply that address; `watch.json["wallets"]` takes any number.

## THE BIGGER FIND: BLOCKSCOUT'S WAF, AND FIVE SERVICES SILENTLY READING ZERO
Discovered because `holdings()` 403'd on its first call.
- **A browser User-Agent is no longer enough.** Probed from the droplet AND from Ben's Mac, identical result, so not an IP ban: site root **200**, any `/api` path with UA only **403**, same path with **UA + `Accept: application/json, text/plain, */*` + `Referer: https://robinhoodchain.blockscout.com/`** **200**.
- **The root staying 200 is why nothing looked broken.** Full detail is now in [[data-access]].
- Five files still sent a bare UA, and **every one used Blockscout for HOLDER COUNTS**, so holders were reading 0/None: `coinbench/bench.py` (the holder tracker itself), `coinbench/ledger.py`, `delta-alert`, `launchwatch`, `parupdate`.
- Patched with a shared `_bsh(url)` helper per file, 5 of 5 clean. Verified after: **DELTA 15,936 holders, PAIR 9,804**. `deltaalert` and `rugwatch` restarted; launchwatch, parupdate and fillwatch are cron so they pick it up on their own.

## SEPARATE FINDING: COINBENCH HAS BEEN DEAD SINCE 5 SEP
- `coinbench.service` was **stopped and disabled cleanly on 5 Sep 2026 at 14:56 UTC**, and `bench.db` has not been written since 14:22 that day. Not a crash, so it was deliberate. **Ben has to say whether to bring it back before anyone re-enables it.** Its holder reads would have been broken anyway until today's header fix.

## PROCESS
- Patches `/opt/rugscan/patch_holdings.py`, `patch_holdings2.py`, `/tmp/patch_bswaf.py`, authored on the Mac in `~/rugscan-deploy/` and scp'd. Backups `.bak-20260909-*` on chain.py, rugscan.py, bench.py, ledger.py, deltaalert.py, launchwatch.py, parupdate.py.
- `safe_write()` (parse -> byte-compile -> import a temp copy -> only then replace the live file) used throughout, per [[rugscan-2]].

### Doctrine additions
13. **`safe_write`'s import gate must copy the whole module directory, not just the `.py` files.** `bench.py` and `deltaalert.py` load a `config.json` at IMPORT time, so a py-only temp dir failed the gate for the wrong reason and nearly blocked two correct patches. Copy every small sibling file.
14. **A hand-kept list of anything will drift from the thing it is meant to mirror.** The fix is never to re-curate the list, it is to derive it from the source of truth on every run. This one had drifted to 0 of 4 correct.
15. **When a source's homepage answers but its API 403s, suspect a WAF HEADER RULE, not an IP ban or rate limiting.** Retry loops and fallback chains cannot fix a header rule, and any bot that catches the exception will report a confident zero instead of an outage. **A metric that silently reads 0 is more dangerous than one that errors.**
