---
name: rugscan-2
description: Continuation of [[rugscan]] from 7 Sep 2026, after that file hit its size cap. Patch 2: the derived scam surface (selector diff, revert strings, proxy check), the empirical escape-rate test, the SCAM tier and notification suppression. Read with [[rugscan]] before touching any detector or explaining a verdict.
sources: [cowork]
aliases: [rugscan part 2, rug-scan-2, scam-tier, exit-check, contract-battery]
---

# rugscan, part 2 (from 7 Sep 2026)

Everything before this is in [[rugscan]]. Same box: **/opt/rugscan** on kookwatch (159.223.88.168).

## PATCH 2 — Ben rejected patch 1 as a half fix, and he was right
- [stated] Ben, after patch 1: "You literally said it never looked at the contract, and obviously it has a sell limit per block. Things like that are obvious scams. Update the feature so that I don't get any notifications for scams and so that our DD process is much improved with all these features that scams would have."
- **Two things were wrong with patch 1.** (a) It SCORED the scam but still SENT the alert, so he still had to read it. (b) Its check list was four revert strings hand-copied off one sample, which only ever catches the scam already seen.

## THE THREE PIECES OF PATCH 2

### 1. `detect.contract_battery(token)` — a DERIVED surface, not a hand list
- Walks the runtime bytecode for **every PUSH4 immediate** (a solidity dispatcher is PUSH4 + EQ) and **diffs the selector set against plain ERC20**. Anything extra is a POWER the token should not need. Graded **3 = can trap you or take from you, 2 = can change the deal, 1 = noise**. This finds functions nobody has written a signature for.
- Plus every ASCII revert string matched against restriction language (a `require()` message survives optimisation and names the restriction in the author's own words).
- Plus an **EIP-1967 proxy check** (`eth_getStorageAt` slot `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc`). **An upgradeable token can change the rules after you buy, so proxy = severity 3 on its own.**
- **Owner logic, and it is not the obvious one: renounced is not safe.** `owner()==0x0` with restrictions present means those restrictions are now PERMANENT. Owner NOT renounced while switches exist escalates to severity 3.
- Unreadable contract = **severity 3**, never 0. Fail closed, same doctrine as the `code()` bug in [[rugscan]].
- Calibration 7 Sep: copycat sev 3 (per-block sell limit, enableTrading, max-wallet, max-tx, 20 extra selectors). Real SNOWBALL, DELTA, OPTIMUS, GG, PONS, PAIR all **sev 0**. TWO sev 2 (only `transferOwnership` on a renounced contract, correctly graded below a trap).

### 2. `detect.exit_check(token)` — THE BEST SINGLE SCAM SIGNAL BUILT SO FAR
**Of the wallets that BOUGHT in the window, what share ever managed to SELL.** Empirical, signature-free, needs no bytecode understanding, so it catches mechanics that have no known fingerprint.
| token | window | buyers | ever sold |
|---|---|---|---|
| **SNOWBALL copycat** | 2h | 733 | **0.0%** |
| SNOWBALL copycat | 3h | 890 | 4.6% |
| real SNOWBALL | 3h | 757 | 38.6% |
| GG | 3h | 94 | 29.8% |
| DELTA | 3h | 84 | 27.4% |
- **Healthy band is 25 to 40%. Zero of 733 is a honeypot operating in real time.**
- Also returns `seller_buyer_ratio` and a **realized sell tax** measured from logs (tokens debited from the seller minus tokens that actually reached the venue, per transaction), which is the truth rather than whatever a getter claims.
- **Returns `None` when it cannot measure** (under 40 buyers in the window, or the log pull blows its 45s budget). **None is never scored as safe** — it prints "exit check could not run, escape rate unknown".
- Soft scoring when not fatal: under 15% escaped and >=60 buyers = +30; under 20% = +15; realized sell tax 5 to 15% = +12.

### 3. The SCAM tier, and Ben stops seeing them
`judge()` now evaluates a **hard gate first and returns immediately**. Any ONE of these is risk 100, action **SCAM**:
- contract battery severity 3;
- **under 10% of at least 100 buyers ever sold**;
- realized sell tax >= 15%;
- a clone of an older live token AND the contract carries switches.
**Suppression: a SCAM is NOT delivered on the `--watchlist` path.** It is appended to `/opt/rugscan/blocked.jsonl` and the run prints `suppressed N scam alert(s)`.
- **The deliberate exception is rugwatch.** That path only fires on a token Ben has ALREADY BOUGHT, so silence there would be the dangerous choice. It still sends, prefixed **"⛔⛔ YOU JUST BOUGHT A SCAM ⛔⛔"**.
- `line()`'s icon lookup was a bare dict index and would have KeyError'd on the new action. It is now `.get(action, "❓")`.

## DEPLOYMENT AND REGRESSION, all verified live 7 Sep 2026
- Patches `/opt/rugscan/patch_contract.py` (11 checks) and `/opt/rugscan/patch_scam.py` (13 checks), both idempotent, exact-string replacement with `assert OLD in s`, `ast.parse` before every write, backups `.bak-20260907-060402` and `.bak-20260907-061927`. Authored on the Mac in `~/rugscan-deploy/`, scp'd, executed on the droplet. **26 self-checks, 0 failed.**
- **Verdict journey of `0x3fbf37267a7a0f54b9062a465a997e4698925910`: CLEAN 0 -> AVOID 100 -> SCAM 100 (suppressed).**
- **real SNOWBALL `0xb19f5a6e22f9057980b08ff05b7f5a6878ab0c8a` CAUTION 37** — correctly NOT a scam despite being 4.8h old and genuinely bundled. This is the discriminator working.
- **DELTA, OPTIMUS, GG, PAIR all still CLEAN 0.** No false positives.
- Suppression filter unit-tested (`blocked: ['0xdead'] delivered: ['0xgood']`), SCAM icon renders, `import rugwatch` clean, `systemctl restart rugwatch` -> active.
- Both patches and their thresholds are written into `/opt/rugscan/README.md` so a later session cannot loosen them back.

## THE DOCTRINE THIS ESTABLISHES, and it generalises past rugscan
1. **A detector's blind spot is a CLASS of evidence, not a threshold.** rugscan was a flow detector calling itself a rug detector: it could see what wallets did and was structurally blind to what the code permits and to whether the token was even the one it claimed to be. Before trusting any clean verdict, ask what class of evidence the thing cannot see at all.
2. **Prefer a DERIVED surface to a hand-written list.** A list of four strings catches the scam you already saw. A selector diff against the standard catches the next one.
3. **Prefer an EMPIRICAL test to a signature.** "Did people actually get out" needed no knowledge of the trick being used, and it was the strongest single number in the whole build.
4. **Scoring a scam is not the same as protecting someone from it.** If the output still lands in his notifications he still has to read it and judge it. Suppress, log, and make the exception exactly where silence would be dangerous (a token he already owns).
5. **Renounced ownership is not safety.** It freezes whatever is already baked in.

## PATCH 3, 7 Sep 2026 — HOODSHOP. My own patch-2 matcher would have MISSED it.
- [stated] Ben pasted a LAUNCHWATCH alert: "this looks like a scam, chart looks weird and marked by fomo as scam." **BUY on HOODSHOP `0xcbd33817967422b644a7e8b74f1e9fd58fbadc7a` ("Robinhood Market", 9 decimals) at 68 minutes old, cap $356k, +9482% on the hour.**
- Its bytecode contains **`Only 30 sells per block!`** and **`trading is already open`**. Same trap family as the SNOWBALL copycat.

### FAILURE 1: literal phrases are not a detector. Patch 2 would have scored this ZERO.
Patch 2 matched the literal strings `"sell limit per block"` (SNOWBALL's wording) and `"trading not open"`. **HOODSHOP phrases the same two traps differently and would have slipped through the fix written an hour earlier.**
- Fix: `_RX`, a **regex table over the SEMANTIC SPACE**, e.g. `sell[s]?\s*(limit|cap|per|allowed)|only\s*\d+\s*sell|max\s*sell`, `per\s*block`, `trading\s*(is\s*)?(not\s*)?(already\s*)?(open|enabled|active|live|started|paused)`. Each hit now reports the matched text so the reason is auditable.
- Verified across **12 tokens**: HOODSHOP sev 3, SNOWBALL copycat sev 3, **sev 0 on SNOWBALL real, DELTA, OPTIMUS, GG, PONS, PAIR, TWO, HOOKR, UP, LUTE**.

### FAILURE 2, the bigger one: LAUNCHWATCH HAD NO SCAM GATE AT ALL
rugscan got the full battery in patches 1 and 2. **launchwatch, `/opt/launchwatch/launchwatch.py`, is the bot that actually FINDS new coins and it was still shipping pure momentum** (accel, volume, off-high, churn, deployer record). I fixed the bot that answers questions and left the bot that makes recommendations untouched.
- Fix: **`scam_veto(ca)` as CHECK 5**, inserted after checks 1-4 and BEFORE `out.append(c)`, so it only costs RPC on candidates that already cleared momentum. Blocked coins are logged to `/opt/launchwatch/blocked.jsonl` and never sent. **Fails OPEN on an exception (an alert with an unknown contract beats no alert) and CLOSED on a contract that reads dangerous.**
- **VETO REGRESSION: HOODSHOP BLOCKED, SNOWBALL copycat BLOCKED; SNOWBALL real, DELTA, GG, PAIR, OPTIMUS all ALLOWED.**

### FAILURE 3: seller/buyer ratio beats escape rate on this case
HOODSHOP escape rate 15.5% (above the 10% hard gate, so it alone would not have fired), but **seller/buyer ratio 0.159 against 0.69 to 0.77 on healthy tokens.** Added to the scam gate: ratio < 0.25 with >= 100 buyers. Live verdict now: **HOODSHOP SCAM 100/100**, naming the sell limit, the per-block cap, the trading gate, "only 38 wallets sold against 239 that bought", and the clone flag.

### DEPLOY BUG, and the process lesson is the point
The patch inserted `sys.path.insert(0, "/opt/rugscan")` into launchwatch, **which did not import `sys`**. The self-check caught it, but **the check ran AFTER `open(L,"w").write(w)`, so launchwatch was actually broken for about two minutes** until `import sys` was added at line 51 and `import launchwatch` verified clean.
- **STANDING RULE for every future patch script: a check that can fail must run BEFORE the write, not after it.** `ast.parse` is not enough, because a missing import parses fine and only explodes at run time. Add an import smoke test to the pre-write gate.

### State after patch 3
- Patch `/opt/rugscan/patch_lw.py`, 9 checks. Backups `.bak-20260907-134...` on detect.py, rugscan.py and launchwatch.py.
- launchwatch cron intact (`*/3 * * * *` under `flock -n`), no tracebacks, cycles running under the new code.
- Written into `/opt/rugscan/README.md` under "PATCH 3".
- **Doctrine point 6, added: fix the whole FLEET, not the bot that was named.** Three bots consume these checks (rugscan on demand, rugwatch on his buys, launchwatch on discovery). A detector improvement that lands in one of them leaves the other two shipping the old verdict, and the discovery bot is the one whose output he acts on.
- **Doctrine point 7: a fix written from one sample is fitted to that sample.** Both scam fixes today failed on the very next case until the matcher was generalised. Before shipping a detector, test it against a token it was NOT built from.

## PATCH 4, 7 Sep 2026 — the shared gate across the fleet, and the LP-pull finding
- [stated] Ben, after being told three gaps remained: "Yep, do that now." (liquidity check first, then wire the checks into the other bots)

### `/opt/rugscan/gate.py` — one module, every bot calls it
`is_scam(ca)` returns **None to allow** or a **reason string to block**. Disk-cached 1h at `gate_cache.json` (several bots scan overlapping token sets every few minutes), hard time budget, **fails OPEN on an exception** (a missed block beats a dead bot) and **CLOSED on a contract that reads dangerous**.
- **NOW GATED: rugscan, rugwatch, launchwatch, fillwatch, pullback.**
- **coinbench deliberately NOT gated:** it only tracks coins Ben chose himself. Instead **all 16 tracked coins were swept through the gate on 7 Sep: 0 flagged** (includes DELTA, OPTIMUS, PAIR, CHIPS, GG, TWO, BOW, PONS, HOOKR).
- Verified: both known scams blocked, and SNOWBALL real / DELTA / GG / PAIR allowed, through `gate.py` standalone.

### THE LP-PULL FINDING, and it is a negative result worth more than the detector
Built `lp_wallet_pct()`: read the pair as an ERC20 LP token, measure the share sitting in **WALLETS** (an EOA holding LP can call removeLiquidity) versus burned to 0xdead or held by a vault. **Contract-held LP is NOT scored**, because a launchpad vault holding 100% of the LP is the normal SAFE pattern here (Snowball does exactly that).
- **MEASURED ACROSS 58 RECENT LAUNCHES ON THIS CHAIN: ZERO had wallet-held LP.** Every launch goes through a pad that burns LP or vaults it.
- **Both known scams (SNOWBALL copycat, HOODSHOP) have LP 100% BURNED** and still trap, via sell limits. The trap here is the exit, not the pool.
- **So on Robinhood Chain today the LP-pull rug is not a live vector.** The leg ships as a cheap guard. **Do not claim it catches anything until a real case shows up in a blocked.jsonl.** One near-case found: Parlay `0x6740c7c4e961b47b82d94801e2954da8b871b420`, LP 0% burned and 100% held by contract `0x2ac03e14cfe755426daaee0a4994184ce81482f8`, liquidity $0 — contract-held so correctly not scored.
- **VENUE DETECTION TRAP: never infer v2 vs v4 from address length.** v4 pool addresses on this chain are also 42 chars. TEST `totalSupply()` on the pair instead. v1 of this scored **sev 2 on DELTA, GG, OPTIMUS and PAIR** by treating "no LP token" as a danger sign when it is simply what v4 looks like.

### THE PROCESS FIX, and it earned its keep the same hour
`safe_write()`: **parse -> byte-compile -> IMPORT the candidate from a temp copy -> only then replace the live file.** This is the standing fix for breaking launchwatch in patch 3.
- It caught a second bug immediately: the idempotency guard `if "_scam" not in p` was **always False because `_scam` is a SUBSTRING of `_is_scam`**, so pullback received the import but not the gate. The check reported FAIL, nothing shipped broken, and it was fixed in one pass.
- **STANDING RULE: an idempotency guard uses a distinctive marker string, never a short fragment that can appear inside the code you just inserted.**

### TWO OPEN ITEMS, both PRE-EXISTING, neither caused by these patches. Told to Ben.
1. **fillwatch has NO schedule at all** — no systemd timer, no cron line, `systemctl is-enabled` returns not-found. It only runs when invoked by hand. Gating it was still right, but it is not currently firing on its own.
2. **launchwatch cycles run 27 to 50 minutes against a 3-minute cron**, so `flock -n` skips most slots and alerts can arrive late. Scales with registry size (134 candidates took 27 min, 264 took ~50), not with the new gate, which only costs RPC on candidates that already cleared momentum.

### Doctrine additions
8. **A negative result is a real deliverable.** Measuring that wallet-held LP does not exist on this chain is worth more than shipping a detector and implying it is protecting him. Say which legs are live and which are dormant guards.
9. **Never infer a venue, a type or a format from a surface feature like address length. Probe it.**

## PATCHES 5 AND 6, 7-8 Sep 2026 — the two PRE-EXISTING problems, both fixed
- [stated] Ben: "yes" (fix fillwatch's missing schedule and launchwatch's slow cycles).

### fillwatch had never been scheduled. Fixed.
It is a CLI whose scanning subcommand is `run`; nothing ever called it. Added to crontab:
`*/30 * * * * cd /opt/fillwatch && /usr/bin/flock -n /tmp/fw.lock /usr/bin/python3 /opt/fillwatch/fillwatch.py run --quiet >> /opt/fillwatch/run.log 2>&1`
Old crontab backed up to `/tmp/ct.bak` before editing.

### launchwatch was taking 75+ MINUTES per cycle against a 3-minute cron. MEASURED, not estimated.
From its own run.log, before any of today's patches:
| cycle | to score | duration |
|---|---|---|
| 21:42:14 -> 22:59:31 | 385 | **77 min** |
| 23:00:46 -> 00:16:16 | 385 | **75 min** |
`flock -n` therefore skipped roughly 25 consecutive runs, and a coin could be alerted **more than an hour** after it crossed the hour mark. **This was pre-existing and had nothing to do with the scam gate**, which only costs RPC on candidates that already cleared momentum.
- **ROOT CAUSE: `hour_one()` is one rate-limited GeckoTerminal minute-candle call per candidate, about 12s under throttling. 385 candidates is 77 minutes no matter what else is true.** Nothing else in the cycle is expensive.

**PATCH 5, `cheap_prefilter()`:** batch DexScreener (30 addresses per call) BEFORE any GT call, and skip any candidate that cannot possibly clear `MIN_VOL` ($50k in hour one). Cut at **0.4 * MIN_VOL**, not at MIN_VOL, because DexScreener h1 is a rolling window while the real test is birth-anchored. **Anything DexScreener cannot price is KEPT, never dropped**, so a missing quote can never silently lose an alert.
- Live result: **77 candidates -> 64 worth a GT call, 13 skipped. Only 17%.** Honest conclusion: **the prefilter alone is not enough**, which is why patch 6 exists.

**PATCH 6, `CYCLE_BUDGET = 150.0` seconds:** score **oldest first** (closest to ageing out of the MIN_AGE..MAX_AGE window), stop when the budget is spent, leave the rest in the registry for the next cycle. 5 checks, 0 failed, backup `.bak-20260908-003042`.
- **THE BUG THIS HAD TO AVOID, and it is the whole reason the patch is not two lines:** the original code dropped every eligible candidate from the registry at COLLECT time, scored or not. Adding a budget without changing that would have **silently deleted coins nobody ever looked at**. So `drop.append(ca)` moved out of the collect loop and now happens only **after** a candidate has actually been scored; anything past MAX_AGE is still dropped unconditionally.

### Process note: safe_write() is now the standard for every droplet patch
parse -> byte-compile -> **IMPORT a temp copy** -> only then replace the live file. Used for patches 4, 5 and 6. It has now caught two bugs before they reached a running bot (the missing `import sys`, and the `_scam`/`_is_scam` substring guard).

### Doctrine addition
10. **Measure the bottleneck before optimising it, and report the measurement even when the first fix is weak.** The prefilter felt like the answer and delivered 17%. The log timestamps said the real shape was "one slow call per candidate, unbounded candidate count", which only a budget fixes. Two consecutive 75-minute cycles in the log were worth more than any amount of reasoning about the code.

## PATCH 7, 8 Sep 2026 — patch 6 shipped a regression. Caught by READING the patched file, not by the checks.
Patch 6 passed 5 of 5 self-checks and was still wrong. The checks proved the strings landed; they could not prove the strings landed in a place where they DO anything.

### The bug
Order in `main()`:
```
for ca in drop: reg.pop(ca)      # registry pruned
json.dump(reg, ...)              # registry SAVED
for c in fresh: ... drop.append(c["ca"])   # patch 6 appended HERE, after the save
```
**`drop.append(c["ca"])` was a no-op.** Scored coins stayed in the registry.

### Why that was WORSE than a no-op
`seen` is consulted only when ADDING new pools from the feed (`if base in seen or base in reg: continue`). **It is NOT consulted when building `fresh` out of the registry.** So a coin already scored would be rebuilt into `fresh` every cycle until it passed MAX_AGE: **re-scored for two hours, growing the very backlog patch 6 existed to shrink.**

### The three fixes
1. **`elif age >= MIN_AGE and ca not in seen:`** — `fresh` now excludes anything already handled. `seen` becomes the single source of truth for "handled"; the registry entry is just pruned at MAX_AGE.
2. **`seen.add()` moved to AFTER the budget check.** It was before it, so the one coin that tripped the break each cycle was marked handled without ever being scored, and would have been skipped forever.
3. **Deleted the no-op `drop.append()`.** Dead code that looks load-bearing is worse than no code.
4 checks, 0 failed, backup `.bak-20260908-003335`. No tracebacks; cycles clean.

### Verified vs NOT verified, state it honestly
- **Verified:** patch applies, imports clean, cycles run with no tracebacks, final score-loop reads correctly (budget check -> seen.add -> score).
- **NOT yet verified empirically:** the budget has not fired, because the manual run earlier consumed the backlog and the registry is rebuilding from 0 candidates. **The proof to look for later: a `budget spent: scored N of M` line in `/opt/launchwatch/run.log`, and cycle durations staying under 3 minutes.** Until that line appears, the budget path is inspected but unexercised.

### Doctrine addition, and it is the most transferable one of the whole day
11. **A passing self-check proves the edit LANDED, not that it WORKS.** Patch 6 was 5/5 green and inert, because the inserted line sat after the state it was meant to affect had already been written to disk. **After every patch, read the patched region in its real surrounding order and trace the data flow.** String assertions verify syntax and placement; only reading verifies semantics.

## PATCH 6/7 VERIFIED, 8 Sep 2026 — proved by a SANDBOX TEST rather than by waiting
The budget path could not be exercised live (an earlier manual run had consumed the backlog, so every cycle read `ready to score: 0`). Instead of waiting an hour, forced it.

### The sandbox method, reusable for any of these bots
Copy `registry.json`/`dep_table.json` to `/tmp/lwtest`, seed `seen.json` as `[]`, then `import launchwatch` and override module constants before calling `main()`:
`LW.P = "/tmp/lwtest/"` (every file read and write follows P, so live state is untouched) · `LW.tg = lambda *a, **k: None` (nothing is sent) · `LW.MIN_AGE = 0` (every registry pool becomes a candidate) · `LW.MAX_AGE = 10**6` · `LW.PAGES = 0` (use the snapshot, do not collect) · `LW.CYCLE_BUDGET = 8.0`.

### Result: both critical properties confirmed
```
registry snapshot: 458 pools, all now eligible
prefilter: 458 candidates -> 405 worth a GT call (53 skipped as too thin)
budget spent: scored 3 of 405, the rest stay unseen for the next cycle
elapsed 27.1s | marked seen: 3 | left in registry: 458
```
- **The budget fires and stops early.**
- **Unscored coins survive: 458 still in the registry, only the 3 actually scored were marked `seen`.** That is the exact regression patch 7 existed to prevent, now demonstrated rather than argued.
- Live `registry.json` (458) and `seen.json` (14,140) confirmed unchanged afterwards; sandbox deleted.

### One real finding from the test: the budget OVERSHOOTS by up to one candidate
8s budget produced a 27.1s run, because the check sits BETWEEN candidates and each `hour_one()` GT call is ~9s. **Effective ceiling = budget + one lookup.** With a 3-minute cron slot and a lookup that can take 30s under retry/backoff, 150s was too tight.
- **`CYCLE_BUDGET` lowered 150.0 -> 120.0.** Worst case ~150s, comfortably inside the 180s slot. Import-tested before the write, backup taken.
- Prefilter on a real 458-pool batch: **458 -> 405, 12% removed.** Consistent with the earlier 17%. It helps; the budget is what actually bounds the cycle.

### Doctrine addition
12. **When a code path will not trigger naturally, force it in a sandbox instead of waiting or shipping unverified.** Redirecting a module's path constant and stubbing its send function turns an hour of waiting into a 30-second decisive test, and it exercised the failure mode (unscored coins being lost) that no amount of reading would have proven.
