---
name: fillwatch
description: The "cheap and filling" tracker Ben asked for on 2 Sep 2026. A droplet service that rebuilds exact holder counts from on-chain Transfer logs every 2 hours for a watchlist of Robinhood Chain micro-sector coins, scores each on four factors, and alerts on quadrant flips. Read before answering anything about tracking a coin over time, holder history, arrival rate, or "add this coin to the tracker".
sources: [cowork]
aliases: [fill-watch, cheap-and-filling, holder-tracker, arrivals-tracker]
---

# fillwatch

Companion to [[alert-bots]], [[rh-memestock-arena]], [[data-access]]. Built and deployed 2 Sep 2026.

- [stated] Ben asked for this after the "cheap and filling" explanation: "Is there any way to get the historical trader account for coins so that we can get an overview every couple of hours if people keep arriving? I like this formula a lot... I would like to make this a system so that when I give you a new coin, you look at that, and then we track the key metrics."

## THE ANSWER TO "CAN WE GET HISTORICAL HOLDER COUNTS": YES, EXACTLY, FROM GENESIS
- **No explorer needed.** Replay every ERC-20 `Transfer` log from the token's deploy block, maintain a running balance map, count addresses with balance > 0. That is the true holder count at any block, and snapshots at any interval reconstruct the full curve retroactively.
- Cost measured on DINO: 78,034 transfers, full replay in 81 seconds. On a 50-day-old token like AI it is roughly 850 RPC calls at 50,000-block windows.
- Blockscout `holders_count` is a convenience number and drifts. The replay is the source of truth.

## WHERE IT LIVES
- **Droplet `kookwatch` 159.223.88.168, `/opt/fillwatch/`.** Per the [[alert-bots]] one rule: everything on the droplet, nothing on the Mac.
- `fillwatch.py` (296 lines), `watchlist.json`, `state/<ca>.json` (balance map + last block), `history.csv`, `config.json`.
- **Cron: `35 */2 * * *`** offset off rhtop's 04:20 slot.
- **Telegram: reuses the existing `rhtracker` bot `@bk_rhwatch_bot` token and chat. NO new bot token was created**, so there is no duplicate-alert risk. Token was copied config-to-config on the droplet and never printed.

## COMMANDS
```
ssh -i ~/kookwatch/id_kookwatch root@159.223.88.168 'cd /opt/fillwatch && python3 fillwatch.py <cmd>'
  add <CA> [label]    find deploy block, full backfill, reconcile, seed state
  run                 incremental update of every coin, append history, Telegram on flips
  report              current table, CHEAP+FILLING sorted to the top
  hist <CA> [hours]   holder curve for one coin
  rm <CA>
```

## THE FOUR FACTORS IT SCORES
1. **ARRIVALS** holders now vs holders last cycle, expressed per hour. Are people showing up.
2. **TURNOVER** 24h volume divided by aggregate liquidity across all pairs. Is money staying or passing through. Under 15x good, over 20x churn.
3. **$/HOLDER** market cap divided by holders. What each person paid to be in the room.
4. **CEILING** biggest cap in the tracked set divided by this cap. How much room is left.
- Quadrant = cheap (at or below the set's median $/holder) crossed with filling (arrivals above zero). **CHEAP+FILLING is the target. cheap+emptying is the trap**: cheap because everyone left.
- Telegram fires on: quadrant flip, turnover crossing 15x, arrivals turning negative.

## THE BUG THAT ALMOST SHIPPED, AND THE GATE THAT NOW BLOCKS IT
- **`rpc.mainnet.chain.robinhood.com` answers a wide `eth_getLogs` window on a busy token with `{"code":-32000,"message":"log query timed out"}`.** The first version treated that as an empty range and silently dropped every transfer in it. Result on DINO: reconstructed balances **22.5% above totalSupply**, one sampled wallet showing 4,699,455 tokens against an on-chain balance of 0, and a holder count of 2,352 against a true 3,552.
- **Fix 1, code: `_getlogs` recursively halves the window on ANY failure and never skips it.** A single unreadable block raises rather than returning empty.
- **Fix 2, gate: `reconcile()` requires sum of positive balances == `totalSupply()` and zero negative balances.** On failure `add` refuses to save state and `run` refuses to advance state or write a history row. Verified PASS at 0.0% drift on DINO and PEPTIDES.
- **STANDING TRAP, generalises beyond this tool: an RPC error inside a log scan is indistinguishable from an empty range unless you check. Any balance reconstruction must reconcile against totalSupply before it is quoted.**
- Second RPC `robinhood-rpc.publicnode.com` 403s after sustained use in a session. Treat it as a fallback that can vanish, not a peer.
- Window sizing measured 2 Sep: 50,000-block windows return in 6 to 10 seconds even on hot ranges (19,086 logs); 9,000-block windows time out on hot tokens. **Use a large step and let the recursive split handle density**, which is far faster than a small fixed step.

## STATUS AT BUILD TIME
- Seeded with the 18 [[rh-memestock-arena]] tokens, ordered youngest first so the useful rows land first. DINO and PEPTIDES reconciled PASS immediately; the 40-day names (AI, MOO, SPACEHOOD) take roughly an hour each of one-time backfill. Incremental cycles afterwards are two RPC calls per coin.
- **CORRECTION owed to Ben: the 30-minute DINO holder curve delivered before the fix (423 rising to 2,675) was produced by the broken scanner and its levels are wrong.** True count at that time was ~3,552. The shape should not be trusted either. Real history starts from the first clean cycle.

## 2 Sep 2026 12:40 UTC — v2: the DISTRIBUTION layer, and an incident
- [stated] Ben named the exact failure mode the four factors cannot see: "apparently it's filling, holder numbers are going up, but it's distributing... maybe price is going down, so maybe some bigger holders are exiting while new members are getting in. How do we make sure that we are correct that this is actually distributing and it's not kind of a temporary phenomenon?" He also asked for a **visualization pushed every 10 minutes**.

### The answer, from first principles
- **Holder count is a COUNT OF ADDRESSES. Total supply is constant.** So a rising count says nothing about where the supply sits. Whales selling into new entrants moves mass down the size ladder while heads go up. Heads is the wrong unit; the fix is to measure MASS BY SIZE COHORT.
- All of it is free: it comes from the balance map the tool already maintains, so it costs zero extra RPC calls.
- **Cohorts by share of FLOAT**: whale >0.5%, mid 0.05 to 0.5%, shrimp <0.05%. Float excludes the PoolManager, the zero and dead addresses, and any large holder whose `eth_getCode` is non-empty AND does not start with `0xef0100`. **That last clause matters: an EIP-7702 delegated wallet has code and IS a real user, so "has code" alone wrongly deletes the biggest cohort of real Robinhood users.** Only the top 400 holders are code-checked; infra is never dust.
- **ABSORPTION RATIO = whale supply LOST / shrimp supply GAINED over the window.** Near 1.0 means small wallets are absorbing whale exits almost exactly. That is distribution wearing the costume of adoption, and it is the single number that answers the question.
- Also tracked per cycle: `top10_pct`, `top50_pct`, `n_whales`, median holder balance, float supply.

### How a temporary blip is prevented from being called distribution
- **Nothing is declared on one delta.** The verdict reads a rolling WINDOW of 6 consecutive cycles (1 hour at the 10-minute cadence) and requires ALL of: holders up, whale share down by at least **0.5 percentage points of float** (the noise floor), price change at or below zero, and median absorption between 0.5 and 2.0.
- Fewer than 6 cycles of evidence prints `DISTRIBUTION?` with the question mark, never the bare verdict. Other states: `WATCH` (whales shedding but price not confirming, i.e. rotation), `FILLING`, `EMPTYING`, `QUIET`, `SEEDING`.
- This encodes the [[screen-log-outcomes]] lesson directly: ARC printed 1.59x then 0.27x inside 24 hours, and a 1.3% holder move was once called "the end". Two reads are not a slope.

### The visualization
- Three stacked panels sharing one time axis, **one y-axis each, never a dual axis**: (1) holder count, (2) share of float by cohort as a stacked area, (3) price. When panel 1 rises while the whale band in panel 2 shrinks, distribution is visible without reading a number.
- Rendered with matplotlib (installed 2 Sep) on a dark surface, pushed to Telegram as a photo via `sendPhoto` with a caption carrying the verdict and the receipts.
- Palette `#BFE3FF / #6FB3E8 / #3A6E9E` is a sequential single-hue ramp, validated with the dataviz skill's own script against the dark surface: CVD separation PASS, normal-vision floor PASS, contrast vs surface PASS on all three.
- **Cadence: every 10 minutes for coins on the `focus` list, plus an immediate push for ANY coin whose verdict newly flips into a distribution state, capped at 4 images per cycle** so the chat stays readable. `fw.sh focus <CA> ...` sets the list.

### THIRD BUG FOUND, and it is the subtle one
- **BAWSAQ reconciled to exactly 100.0% of totalSupply and still had 7 negative balances.** That is the signature of DUPLICATE logs, not missing ones: a duplicate credits one address and debits another that never held the tokens, so the sum stays right while individual wallets go negative.
- **A sum-only reconciliation cannot see it. The negatives check can.** Fix: `scan()` now deduplicates on `(transactionHash, logIndex)`.
- **STANDING RULE: any ledger rebuilt from logs needs BOTH checks, the sum against totalSupply AND zero negative balances. Either alone passes a real corruption.**
- Fourth bug, mundane but total: the distribution layer was appended AFTER the `if __name__=="__main__":` block, so every function in it was undefined at call time. **Always keep the main block last in the file.**

### INCIDENT, 12:30 to 12:40 UTC — I overloaded the droplet
- The unserialised 18-token backfill, plus manual test runs, plus a 10-minute cron, drove **load average to 29.98 on a 961MB, small-vCPU droplet that also hosts all ten alert bots**. SSH stopped answering for several minutes.
- Stopped everything, cleared the cron, load recovered to 0.99 within two minutes. **No bot was harmed: the four units showing inactive (tonalert, basewatch, bwbot, watchdog) have an EMPTY `ActiveExitTimestamp`, meaning they have not been active at any point in the 10-day uptime, so their state predates this work.** crashbot, gramwatch, xalert, rhtracker, ansemtracker and kookwatch stayed active throughout.
- **Permanent fix, `/opt/fillwatch/fw.sh`: `flock -n` for single-instance plus `nice -n 15 ionice -c3`.** flock -n means a cron firing while a run is still going exits immediately instead of piling up. The cron and the seed script both go through the wrapper now.
- **STANDING RULE for anything added to this droplet: it shares 961MB and a small CPU with ten alert bots Ben depends on. Every scheduled job gets flock and nice, and a long backfill is never run concurrently with its own cron.**

### Current state
- Cron: `*/10 * * * * /opt/fillwatch/fw.sh run`. Seed re-running under the wrapper from 12:40 UTC.
- Focus list set to DINO, GG, SAYLORMOON, BONER.
- New commands: `fw.sh dist [CA]` prints the cohort table and verdict; `fw.sh focus <CA>...` sets which coins get a chart every cycle.
