---
name: alert-bot-fleet-5
description: Continuation of the alert bot fleet change log from 12 Sep 2026, after alert-bot-fleet-4.md reached its size cap. Read all five whenever a bot, alert, tracker or watcher comes up.
sources: [cowork]
aliases: [alert bots part 5, linkscan cadence]
---
Continues [[alert-bot-fleet-4]], [[alert-bot-fleet-3]], [[alert-bot-fleet-2]] and [[alert-bot-fleet]]. Same droplet `159.223.88.168`, same rules: everything runs on the droplet, nothing on the Mac.

## 12 Sep 2026: LINKSCAN discovery scan moved to every 5 minutes
- [stated] Ben, right after the fix that made the universe scan able to alert at all: "Run it every 5 minutes."
- **Cron is now `*/5 * * * *` on `/opt/linkscan/backfill.py`** (was 6-hourly, briefly `5,35`). `flock -n /tmp/lsbf.lock` unchanged, so a run that overruns is skipped rather than doubled. Verified firing on its own at 07:45:02 UTC.
- **Two cost patches were needed first, because a 5-minute cadence turns small waste into thousands of API calls a day.**
- **Patch 2 (`backfill.py.bak-20260912-073817`), two things:** `universe()` was being called TWICE per run, once for the list and once only to log its length, so every run did the 20-page GeckoTerminal sweep twice. Now called once. And the GeckoTerminal sweep is gated to every Nth run by `gecko_every` (default 6, so roughly half-hourly) using a counter in `bf_runs.json`; the log line now says `(deep)` or `(quick)`. **Quick runs lose nothing that matters, because `/opt/launchwatch/registry.json` is refreshed by its own */3 cron and already contains every new pool on the chain.** That registry, not GeckoTerminal, is what makes 5-minute discovery real.
- **Patch 3 (`backfill.py.bak-20260912-074319`):** an address only entered `backfill_done.json` if DexScreener returned a pair for it, so the ~1,100 registry entries that never get an indexed pool were re-sent to the API on every run forever (about 36 wasted calls a run, 10k a day at this cadence). Misses are now counted in `bf_misses.json` and an address retires into done after `max_misses` runs (default 12 = one hour of grace). A pool not indexed an hour after launch has nothing to screen. Runtime settles to a few seconds once the backlog retires; it was 21 to 23 seconds during the transition.
- Config now carries, all tunable without a code edit: `backfill_alert`, `backfill_max_age_hours` 24, `backfill_max_alerts` 6, `gecko_every` 6, `max_misses` 12, on top of `min_mcap` 20000 and `alert_on` ["LOOK"].
- Droplet after the change: 169 MB available, cron and linkscan.service both active.
- **Generalisable: before raising any job's frequency, count its external calls per run first.** Going 6h to 5min is a 72x multiplier on every wasted call in the loop, and both of the above were invisible at the old cadence.

## 12 Sep 2026: PAIREX MONITOR, the first bot that watches a TEAM rather than a wallet or a token
- [stated] Ben: "add a monitoring tool on whatever the devs are building, and I want to get alerted on any major developments on their platform features, on fee switches, and all kinds of very relevant changes for investors. Build a Telegram bot, a new one... we call it PAIREX Monitor."
- **`/opt/pairexmon` on kookwatch, unit `pairexmon.service`, log `/opt/pairexmon/pairexmon.log`, `MemoryMax=160M`.** Registered in the droplet botctl registry (28 bots), the Mac `~/botctl/registry.json`, and the watchdog `UNITS` table. Queue-when-no-token pattern, so it banked alerts from minute one while BotFather was still pending.
- **The design premise, and it is the reusable part: PAIREX has NO contractual value accrual, so the only things that can change the thesis are things the devs DO. So the bot watches the doing, not the chart.** Seven watchers: (1) the FX issuer `0x08c82f106d452690ac6335f6676c5f1ae99fb063` for `currencyCount`, `mintBudget` and above all **`directInventory` going above zero, which is the OTC desk switching on** (2) `owner`/`keeper`/`vault` on that issuer, because one EOA controls the mint, the rate and the budgets for all 157 currencies (3) **peg drift** of fxCNY/fxTHB/fxILS against live FX from `open.er-api.com`, which is how you see the keeper stop (4) the deployer's manual buy-and-burn, alerting when it GOES QUIET and screaming if PAIREX ever goes to a pool instead of to dead (5) HoodLock lock 353 with countdown warnings at 7d, 24h and 1h to the 12 Oct 2026 06:29 UTC unlock (6) pairex.market coin count, denominations, biggest community coin and **any new nav route, which is how a feature appears before it is announced** (7) @pairexmarket.
- **X GOTCHA CARRIED FORWARD AND IT MATTERED: @pairexmarket is not in X's Latest index, so the bot queries `queryType=Top`.** Without that the X watcher would have been permanently silent and looked fine.
- **PATCH 1 the same hour, and the lesson generalises to the whole fleet: never let one cycle do unbounded work.** The first build's `watch_currencies` did ~500 sequential eth_calls in cycle 0 (157 codes, three reads each, with retry backoff) and the bot sat for over two minutes writing nothing, which is indistinguishable from hung. Fixed with `CODES_PER_RUN` 12 and `CODES_DISCOVER` 20 against a `code_cursor` in state, `tries=1` inside the per-code loop so backoff cannot stack, **state saved after EVERY watcher rather than at the end of the cycle**, and a per-watcher timing line logged whenever a watcher exceeds 5s. Cycle now runs 5s (issuer) + 1.7s (deployer) + 0.9s (lock) + 25s (currencies, sliced) + 0.9s (peg) + 0.8s (site) + 1.8s (X).
- **IT EARNED ITS KEEP ON THE FIRST CYCLE.** Findings inside two minutes of going live, all of which the 07:45 deep dive had wrong or missing: **indexed coins 37 to 255** and **denominations 14 to 46** in about three hours · new site routes `/claim` and `/money` that did not exist at the screen · @pairexmarket announcing **"fxUSD"** among the quote assets, which is the one currency I had specifically noted was absent and would pull in US regulators · **"Fee claims should be fixed now. All platform users can now claim!"**, so there IS a platform fee claim · they are #1 trending on FOMO and are publicly struggling with server load. Peg measured healthy: fxCNY +0.21%, fxTHB +0.85%, fxILS +1.17%.
- Commands: `/status` (burn total, lock countdown, dev dry powder, currencies, coins, live peg drift), `/mute`, `/unmute`. `settoken.py` reads `token.txt`, authenticates, sends proof-of-life, deletes the file, never prints the token.
- **BOT IS `@bk_pairexmon_bot`, created 12 Sep 2026 by driving Telegram Desktop end to end, no clicks from Ben.** The working sequence, and it confirms and extends the earlier notes: search BotFather in the global search (`computer_app_type` works there), open the chat, then for EVERY message **`computer_app_type` to put the text in the input, `computer_app_release` to drop the background lock, `computer_left_click` on the input, then `cmd+return`**. Background `app_key return` types a newline and never sends; display-scope `cmd+return` always sends. **The two scopes cannot mix inside one turn without `computer_app_release` first**, which is the error that cost a round trip. Token moved by clicking the BotFather code block then `pbpaste | ssh ... 'cat > token.txt'`, then `pbcopy < /dev/null`; it was never printed. START pressed by searching the username and clicking the row, because the t.me link in BotFather's own message does not open it. Name, description and command list set afterwards with `setMyName`/`setMyDescription`/`setMyShortDescription`/`setMyCommands` over the Bot API rather than fighting BotFather.
- **PATCH 3, AND IT IS THE MOST IMPORTANT LESSON OF THE BUILD. The loudest alert in the bot fired NINE times on the first run and every one was WRONG.** `directInventory(code)` is a wei uint, and every currency carries a rounding remainder of order 1e-16 units left over from the peg wall being moved, so the test `di > 0` read 83 wei as "the OTC desk just turned on". Verified against chain: EUR 8.3e-17, JPY 2.9e-15, NGN 1.065e-14, and the desk is still OFF. Fixed with `DIRECT_MIN = 10**18` (one whole currency unit), the same floor applied to the mint-budget comparison, the stored baselines reset, and the alert now prints the amount so the number can be judged rather than trusted. **A correction was pushed to Telegram naming the wrong alerts explicitly.** Generalisable and it belongs in onchain-forensics: **never test a token balance against zero, dust is always there; test against a threshold that means something in the unit a human uses, and put that amount in the output.**
- **PATCH 2: the home-page fingerprint was a noise generator** (it fired twice in ten minutes) because pairex.market is a live market feed and the coin list churns constantly even with digits stripped. Replaced with a `/docs` watcher that diffs the page SENTENCE BY SENTENCE and says what was added or removed, flagged bullish when the added text mentions holder, reward, stake, buyback, burn, revenue share, distribut, fee switch or airdrop. **Fingerprint the spec, never the feed.** Also `http()` now drops the JSON content-type on GETs and `watch_docs` retries three times, which is why the first docs capture returned nothing.

## 12 Sep 2026: XDECK built, Ben's private TweetDeck
- [stated] Ben asked for a Hootsuite replacement, then: "Okay, yes, build it, and you can use the Twitter Data API to feed it." Spec he gave: adjustable column width, text auto-adjusting to the width, real-time feeding, optional sound per column, three column trigger types (one account, one keyword, linked keywords like "moo or memory cow"), and a global keyword exclusion across all columns to kill scam and spam.
- **Live at `http://159.223.88.168:8088/?k=2Z3igtB8Ltu47j_tZVhtE02U`.** Unit `xdeck.service`, dir `/opt/xdeck`, `MemoryMax=160M`, actual use 17 MB. ufw 8088/tcp opened. Registered in both botctl registries (droplet 27 bots, Mac 28) and in the watchdog `UNITS` table. No key in the URL means 403.
- **The twitterapi.io realtime contract, all verified live this session, none of it was documented anywhere we had:**
  - Socket `wss://ws.twitterapi.io/twitter/tweet/websocket`, auth by the **`X-API-Key` HEADER**. The `?apiKey=` query param 403s at the Cloudflare edge. A SECOND simultaneous connection on the same key also 403s, so exactly one process may hold it.
  - Rules: `GET /oapi/tweet_filter/get_rules`, `POST /oapi/tweet_filter/add_rule` {tag, value, interval_seconds} -> rule_id, `POST /oapi/tweet_filter/update_rule` {rule_id, tag, value, interval_seconds, is_effect}, and **`DELETE /oapi/tweet_filter/delete_rule` {rule_id}** (POST on delete returns 405). No rule-count limit hit at 14.
  - **`add_rule` creates the rule with `is_effect: 0`, which is DEAD. Only `update_rule` arms it.** A new column stayed silent until the next config save before this was patched.
  - Envelope is `{rule_id, rule_tag, rule_value, event_type: "tweet", timestamp, tweets: [...]}` and each tweet carries the full author object with `followers`, plus media, quoted_tweet, and the counts. **Measured latency post-to-screen was 6 seconds.**
  - **Cost is roughly 15 credits per delivered tweet.** A broad keyword ("bitcoin" at interval 5) burned 645 credits in 100 seconds. Balance was 1.62M credits at build time, so a wide column is the one thing that can drain it. The header shows kept/blocked and the live credit count for exactly that reason.
- **Architecture decisions worth keeping:** one column = one server-side rule tagged `xd_<column id>`. The global exclude words, the blocked-handle list and the min-followers floor are applied IN THE SERVICE, not in the rule value, so editing them is instant and free instead of N rule rewrites. The browser LONG-POLLS `/api/poll`; SSE dies silently when a laptop sleeps and a long poll just returns and re-arms. A boot `backfill()` runs one `advanced_search` per column so the deck is never blank after a restart (about 1,800 credits, once). `resync_loop()` re-syncs rules every 10 minutes as self-heal.
- **Min followers is the strongest spam lever on X**, set to 150 globally by default. First hour ran 17 kept / 8 blocked, so the exclude list is doing real work.
- Bug that killed the first boot: `compile_excludes()` parks compiled `re.Pattern` objects on each column as `_ex`, and `save_cfg` then json.dump'd them. **Any config that gets decorated in memory must be stripped of private keys before it is written back.**
- A restart always costs about 17 seconds of reconnect, because the old websocket lingers and the new one 403s until the server drops it. The exponential backoff handles it; do not treat that 403 as a failure.

### XDECK v2, same day, after Ben used it for ten minutes
- [stated] Ben: "the ghost column isn't sorted by date... I need to see the newest posts on top." Plus: drag and drop columns left and right to rearrange them by what he cares about that day, rename by clicking the title, edit the search criteria by clicking under the title instead of opening settings, and "I should be able to just enter a Twitter account, which it then follows."
- **Shipped:** a grip handle on each column header for HTML5 drag-to-reorder (drop left or right half of a target column decides the side), click-the-title inline rename, and a second header line showing what the column watches which becomes an input on click. The gear button now holds only the details: sound, min followers, language, hide replies, hide retweets, per-column excludes.
- **One box, mixed input.** `@vladtenev, $DELTA, memory cow` is now a single valid column: `@` entries become `from:`, the rest stay keywords, all OR'd. Type is derived, never picked. The badge reads FROM, WORDS, MIX or QUERY, and clicking it only toggles raw X-search mode.
- **The bug that made this necessary:** before the fix a column was either accounts or keywords. Ben typed `pairexmarket $pairex` into an account column and got the rule `from:pairexmarket $pairex`, which is an AND of a handle and a word and matches nothing. Watch for this shape: a UI that forces one kind per field will silently produce a valid-looking query that can never match.
- **Duplicate-render bug, worth remembering as a pattern.** The first ordered-insert implementation recorded each new item's insertion index, then rendered by index after the whole batch was spliced in. Every later splice shifted the earlier indices, so a 20-item backfill rendered `buf[0]` twenty times and every column showed the same post repeated. **Never hold an array index across mutations of that array.** Fixed by merging the batch first, then either prepending (when every new post is newer than the current top, which preserves scroll) or resorting and repainting the column.
- Upstream rule `interval_seconds` lowered from 5 to 2. Cost is per tweet delivered, not per poll, so a tighter interval is free.
- Both backfill and each websocket batch are now sorted oldest-first before being pushed, because `advanced_search` returns newest-first and a stream batch has no guaranteed order.
- **Deploy lesson: shipping a 23 KB file as one base64 blob through the Mac cost a debug cycle.** A single character was mis-transcribed (a trailing `7` became `6`), gunzip failed with a CRC error, and finding it needed a windowed md5 comparison. Ship a small patch script that does targeted string replacement and prints the resulting md5 to compare against the container copy. Backups on the droplet: `index.html.bak-v1/v2/v3`, `xdeck.py.bak-p0..p4`.

### XDECK v3: clickable posts and the spam/scam scorer
- [stated] Ben: "Make all the posts clickable. When I click on it, it should take me to Twitter and that specific post. In a separate tab." And: build an anti-spam and anti-scam filter that kills phishing links and "the pump groups that are only saying 12x from my call and 50x from my call", but **keeps** the credible ones: "I want good people who say, I called this in whatever at 250k. I want to see those guys because they might be really good, but I don't want to see these low-level guys that are just basically lying about how early they called something." He asked for it as a **Spam On / Spam Off button next to Sound On/Off so he can compare the results**, with presets he can edit.
- **Clicking anywhere on a post opens that exact post on x.com in a new tab.** Delegated listener on `#deck`; a click on a link inside the post keeps its own destination.
- **The scorer TAGS, it does not drop.** Every post gets `sp` (score) and `wh` (the reasons) attached server-side in `slim()`, and the page decides what to show. That is what makes the toggle instant and the comparison honest. Cost is unchanged because the tweet was already delivered and paid for either way.
- **The design that matters: CONTENT signals and ACCOUNT signals are scored separately, and a post is NEVER hidden on account signals alone.** Content = phishing phrase (+6), shill/paid-group phrase (+2 each, cap 4), call-brag with no standing (+3), cashtag stuffing (+2), hashtag stuffing (+1, or +3 at 3x the limit), identical text from N accounts (+4). Account = age under 30d (+2) or 90d (+1), follow ratio under 0.3 with under 2k followers (+2), over 80 posts/day (+2), zero engagement on 3k+ followers (+1). Default cutoff 3.
- **Why that split exists:** the first live run flagged `@LongLaunches`, which is a legitimate long.xyz launch feed, purely because it is 6 days old and posts 164 times a day. Those are account signals with zero content signal. **A brand new, high-volume account is what a useful data bot looks like AND what a spam farm looks like; only the content separates them.** There is also an explicit `allow` list (seeded with longlaunches, ghostdotenv) that returns 0 whatever the score.
- **The credible-account rebate zeroes ACCOUNT points only, never content points.** A 74k-follower verified account blasting 15 hashtags at five different people is still spam. That is what caught `@Atlantis_Ex`, which was replying the identical "How to Trade USPTO-Registered Atlantis Coin" post to five separate handles.
- **The brag rule is the one Ben specifically asked for and it works as specified.** `@itsmeflow18` posting "57.8x up from my call on $Pairex, TG frens eating good" scored 4 ("call-brag, no standing; no engagement on 3107 followers") and is hidden; the same sentence from an account with 5k+ followers, 180+ days old and a follow ratio at or above 1.0 is never penalised.
- Everything is editable in the excludes panel: cutoff score, the two trusted thresholds, max cashtags/hashtags, the duplicate-account count, both phrase lists, and the always-show list. With the filter OFF, every flagged post carries an orange tag naming its score and reasons, which is how the tuning is meant to be done.
- Generalisable: **when a classifier must be tunable by the user, score and tag at the source and filter at the view.** Dropping at the source makes "compare on against off" impossible and makes every threshold change a re-fetch.
- Droplet backups now `index.html.bak-v1..v5`, `xdeck.py.bak-p0..p6`. The full-file gzip+base64 transfer through the Mac was byte-exact on the second attempt; verify with an md5 on the Mac BEFORE sending on to the droplet.

### XDECK v4: real scrollback, and the spam rule that actually catches pump groups
- [stated] Ben: "I should be able to scroll down further. Right now, it seems like it's kind of limited. If I scroll down further, I should be able to see more from that column."
- **Diagnosis, from first principles:** a column could only ever show what had arrived since the page opened. The live websocket buffer plus a 25-post boot backfill is maybe 20 posts per column. There was no history to scroll to, so the fix is not a bigger buffer, it is **fetching real history on demand**.
- **New `POST /api/older` {column_id, cursor}** runs `advanced_search?queryType=Latest` with the cursor and returns {items, cursor, more}. **Verified pagination contract: 20 tweets a page, `next_cursor` + `has_next_page`, and ZERO overlap between consecutive pages** (page 1 oldest 17:12:20, page 2 newest 16:59:19). The page fires it when a column scrolls within 500px of its bottom, appends at the bottom (older than everything drawn, so the scroll position does not move), and stops on `more:false`. Client buffer `MAXBUF` 2000, server `FEED` deque 12000, first load asks `back=3000`.
- **The cursor is reset whenever the column's query text changes**, otherwise a renamed or re-scoped column pages through the wrong history.
- **THE IMPORTANT FIND.** Scrolling back immediately surfaced the exact posts Ben complained about, and v3's scorer gave them ZERO. `"$PAIREX x15.3 VIP Members caught x18 early at $106.8K! Alphas Only Solwave, Public Channel"`, `"122x from automated call"`, `"Another 100x profit from VIP, we're printing"`. The v3 shill list had "vip group" and "alpha group"; the actual text says "VIP Members" and "Alphas Only". **A hand-written phrase list will always be one wording behind the spammer.**
- **The fix is a two-part structural rule, not more phrases.** A pump-group advert is always **a multiplier claim AND a reference to the group selling the call**, together. `MULT` matches `122x`, `x15.3`, `25 x`. `GROUPY` matches vip, members caught, public/private channel, alphas only, signal, telegram, t.me/, my/our/the call, automated call, bot caught, caught at/early, we're printing, profit from, calls out, entry at, i/we called, called it, frens eating, early at, since the call, influencer signal, best print. Both present and not credible: **+4, hidden**. Both present and credible: **+2, kept** — which is precisely Ben's "I want the good people who say I called it at 250k". A multiplier alone scores nothing, because a chart post saying "10x" is information.
- **Measured on the PAIREX column after the fix: 15 of 40 posts hidden, and the one remaining borderline post was a rude reply, not spam.** The whole @sho_design_ VIP ladder (11 near-identical "VIP Members caught x25.6 early" posts), @chinapumprocket, @sharkapescall and @itsmeflow18 all gone. Before the fix: 0 of 51.
- Generalisable, and worth carrying to rugscan and linkscan too: **when a keyword list keeps missing, stop extending the list and find the two-signal structure the thing cannot exist without.** The advert cannot work without both the number and the group, so requiring the conjunction is both stricter and more general than any list.
- Also: **the stored `spam` block in config.json shadows the code defaults.** After widening the defaults, `config["spam"]` has to be deleted for them to take effect. Any "defaults plus stored overrides" config has this trap.
- Droplet backups now `index.html.bak-v1..v6`, `xdeck.py.bak-p0..p9`. Droplet at 156 MB available with xdeck running.

### XDECK v5: the empty-column cause, and auto-translate
- [stated] Ben: "I added from PAIREX market, but it's not showing any posts, even though the account did post." And separately: "Add an auto-translate feature that shows me everything in the English language. You can add a little icon and show me that this is a translated post."
- **THE CAUSE, and it is a keeper. X keeps young accounts OUT of the Latest (real-time) search index while still serving them on Top.** `@pairexmarket`: 983 followers, 36 posts, account created 8 Sep 2026, verified, not protected. `from:pairexmarket` on `queryType=Latest` returned **0**. The same query on `queryType=Top` returned **15**, newest 3 minutes old. `user/last_tweets` also returned 0, by userName AND by userId. So nothing in xdeck was at fault and no amount of filter tuning would have found it. **Any column on a brand new project account will look permanently dead.** Always check Top before concluding an account has no posts.
- **Fix:** `search_auto(col)` runs Latest, and if it returns nothing on a first page it retries Top; if Top has results the column is marked `mode: "top"` permanently in config.json. Detection is free because it happens inside the boot backfill and the new-column preview, searches already being paid for. The @pairexmarket column self-detected on the next restart and backfilled 17 posts.
- **`top_poll_loop()` covers the live side**, since the upstream websocket rule engine is on the same blind Latest index. Every 45s it re-runs the Top search for `mode: "top"` columns **scoped with `since_time:<epoch>`**. That scoping is the whole trick: **a full Top search on that column cost 1,923 credits, the identical search with `since_time` cost 0.** Remember `since_time` / `until_time` for any polling job against advanced_search.
- **Auto-translate.** Non-English posts are rendered in English with a green `🌐 translated` badge; clicking the badge swaps to the original and back, and the click is stopped from opening the tweet. A `translate on/off` button sits next to sound and spam. Only cards actually on screen and not already done are sent, 12 at a time.
- **Translation source, after testing everything reachable:** Google's `translate_a/single` endpoint **IP-blocks the droplet** (returns its "Sorry..." abuse page). Every LibreTranslate mirror tried was 403, 301, 502, key-walled or empty. Lingva mirrors were behind Cloudflare or erroring. **MyMemory (`api.mymemory.translated.net/get?q=&langpair=zh|en`) works, no key, good quality**, but it needs an explicit source language (`auto` is rejected) and the anonymous quota is about 5,000 characters a day. X gives us the source language in the tweet's `lang`, so that is solved.
- **Chrome's own built-in `Translator` API would be free, local and unlimited, and Ben is on Chrome 152, but it is undefined because the built-in AI APIs require a SECURE CONTEXT and the page is served over plain HTTP.** Putting xdeck behind HTTPS (a sslip.io hostname plus Let's Encrypt would do it) would unlock it and remove the quota entirely. That is the upgrade path if the quota ever bites.
- To stretch the small quota: URLs, @mentions and $cashtags are stripped before translating (a t.co link alone is 23 characters), and results are cached on disk in `/opt/xdeck/trcache.json` **keyed by a hash of the text, not the tweet id**, so the same Chinese boilerplate posted by forty accounts is paid for once. A per-UTC-day character counter stops at 4,800 and the endpoint reports what is left.
- Droplet backups now `index.html.bak-v1..v7`, `xdeck.py.bak-p0..p11`. 143 MB available.

### XDECK v6: tab icon
- [stated] Ben: "Add a cool icon at the top of the tab so that when I'm looking at all my open tabs, I know what tab this is."
- An inline SVG favicon as a `data:` URI in the head, so there is no second file to serve and no request: a dark rounded tile with three blue columns stepping down (the deck) and a green live dot top right. Rendered and checked at 16, 32 and 128 px before shipping. Also set as `apple-touch-icon`, plus `theme-color` `#0b0d10`. The `<title>` was already `xdeck`.
- Built by a patch script that CONSTRUCTS the percent-encoded data URI on the droplet rather than transcribing 1,639 encoded characters through the Mac. **When a payload is generated rather than authored, ship the generator, not the output.**
- `index.html.bak-v8`.

## 12 Sep 2026: LINKSCAN v2, the PRODUCT test and the launchpad gate
- [stated] Ben, pasting the FOMO alert (`0x834559FA563b5FFBCB2Bf620C08F5C05738a257f`, fomorobin.site, a pink hippo memecoin that scored WORTH A LOOK): "There is no technical product here. This is just a smart contract or some on-chain thing that is correct... I'm really interested in products here. Somebody built some type of a product that's live and you test it and it's working. It doesn't have to be 100% perfect, but there's a prototype or there's something and it's live and it's working." Plus: **priority long.xyz pairs, open it up to Pons and o1 as well for volume while the benchmark builds.**
- **WHY FOMO PASSED, and it is the same error class as the drainer campaign: the old score gave 2 points for "named contracts are real".** Every launch names its own token, its pool and a router, all of which exist on chain, so that signal was present on every token and separated none of them. **A signal that every member of the set carries is noise, not evidence.** FOMO scored contracts(2) + words>250(1) = 3 and cleared the threshold with a 0.5-day domain and a 0-day-old X account.

### The product test, `/opt/linkscan/product.py`
- **Derived from the mechanism: a product is something a user can operate that does work for them.** A landing page, a whitepaper and a token contract are not that. Evidence is split in two and the split is the whole design.
- **GROUP A, proof it RUNS (at least one is REQUIRED, no exceptions):** (1) `api` a discovered endpoint returns 200 JSON that is non-empty, over 40 bytes and not an error shape · (2) `routes` a path beyond `/` returns its own page, checked by simhash distance >= 8 from the homepage so a catch-all route cannot fake it · (3) `contract` an address the site names that is NOT the token, NOT shared infra, NOT another pad-minted token, NOT a pool, has over 500 bytes of code AND has emitted logs.
- **GROUP B, proof it was BUILT like an app (supporting only):** `web3` (wallet connect, wagmi/viem/ethers/walletconnect/privy), `shell` (framework root or over 120KB of first-party JS), `controls` (3+ inputs/buttons/forms).
- **Verdict: zero Group A hits is the new verdict `SITE ONLY`, which never alerts.** Otherwise score = 2A + B, plus one each for words>250, domain older than 14 days, X over 1,000 followers; **LOOK needs 4**.
- Verified end to end on five cases: **FOMO SITE ONLY** (was LOOK) · **pairex.market LOOK score 8** (/portfolio, /docs, 4 used contracts, wallet, bundle, controls) · **route.fun LOOK score 5** · long.xyz DEAD (Cloudflare 403, expected) · NUDES DEAD (X link only, no site).

### Four bugs found while building it, all of which had been silently degrading every scan
- **`fingerprint()` returned asset BASENAMES, not urls.** `assets` was `a.split("/")[-1]` because the copy-detector hashes filenames. So `fetch_js` was resolving `3418-abc.js` against the domain root, 404ing every time, and **the JS bundle had never once been read**. On route.fun the fix took it from 0 bytes to 1,220,598 bytes and surfaced three real API endpoints. Added `asset_urls` alongside `assets`.
- **`ADDR_PAT` was `0x[a-fA-F0-9]{40}` with no boundary**, so it matched the first 40 hex characters of any contract-bytecode blob inside a JS bundle and reported them as contract addresses. Now `(?<![a-fA-F0-9])0x[a-fA-F0-9]{40}(?![a-fA-F0-9])`.
- **Third-party JS was being counted as the project's own app.** fomorobin.site's only script is Twitter's `widgets.js`, 94KB of somebody else's code. `fetch_js` now skips any script not on the site's own domain.
- **The FAKE rule "names N contracts, none exist on chain" was being fed JS-bundle addresses** and so branded route.fun, a multi-chain aggregator, a fake for carrying Ethereum and Base addresses. **Split the sources: what the HTML SHOWS a human is a claim and can be a lie; what sits in the JS is plumbing and can only ever be positive evidence.**

### The launchpad gate, `/opt/linkscan/pads.py`
- **DexScreener is useless for this on Robinhood Chain: `dexId` reads "uniswap" with label v2/v3/v4 for everything, never the pad.** GeckoTerminal DOES label it (`pons-v2-dex`, `o1-launchpad-robinhood`, `bankr-robinhood`) but is rate-limited and blocked from the container.
- **So the pad is read from the TOKEN BYTECODE, one `eth_getCode`, because every pad clones the same contract.** Calibrated by joining 45 GeckoTerminal-labelled tokens against their code, plus 7 o1 tokens pulled from the o1 pool list:
  - **long.xyz = 44 bytes, an EIP-1167 minimal proxy to `0x3be8b97fd0e713b5abe0649fa830223b6b4bc599`** (DopplerERC20V1). Prefix `0x3d3d3d3d363d3d37363d73`, impl at chars 24:64.
  - **Pons = exactly 3248 bytes** (8 of 9 pons-v2-dex pools in the sample).
  - **o1 = 4657 or 4724 bytes**, two versions, confirmed on NUDES, FATCOIN, REDCAT, O1BOT, OSR, ONKEY, SCAT.
  - Vanity suffixes corroborate but are NOT the rule: long.xyz addresses end `e18`, o1 addresses end `01`.
- `config.json` now has `pads_allow` ["long","pons","o1"], `pads_priority` ["long"], `pads_require` true. Anything else returns the new verdict **`OFF PAD`** from the first check in `scan_one`, before any HTTP, so it costs one RPC call and nothing else.
- `pads.INFRA` is the shared-address exclusion list (v4 PoolManager, v3 NFPM, the Pons curve/factory/locker/hook/platform, the fomo settlement router, LongLauncher, the Doppler airlock/initializer/factory/impl, the o1 router, two aggregators). **A pool is excluded generically by calling `token0()` on it rather than by listing pool addresses**, since pools are derived.
- **Measured on 477 tokens, 12 Sep: 436 no links, 41 link-bearing, of which 17 OFF PAD, leaving 24 on the three pads: 7 LOOK, 7 DEAD, 6 SITE ONLY, 3 COPY, 1 FAKE.** The 7 LOOKs are pairex.market, darkroute.exchange, pstock.finance, poncall.app, ronolaunch.com, usesnap.family, perdiem.fun, every one of them a tool with a site rather than an animal with a site. Compare the pre-change benchmark in [[alert-bot-fleet-4]]: 11.8% LOOK across all link-bearing tokens with no product requirement at all.
- The alert card now prints the pad and a ✓ line per piece of product evidence ("2 live API endpoint(s) returning data", "app pages: /app", "has working controls"). Sample sent to Telegram on RONO, correctly tagged Pons.
- Backups on the droplet: `scan.py.bak-*`/`.padbak-*`/`.jsbak-*`/`.f3bak-*`, `checks.py.jsbak-*`/`.f2bak-*`, `core.py.fbak-*`, `backfill.py.wbak-*`/`.bbak-*`, `linkscan.py.wbak-*`, and the pre-change benchmark at `bench.json.preproduct`.
- **Operational note that cost a confusing result: the `*/5` cron backfill races any manual run.** A manual pass reported "558 left to enrich / enriched 0" because cron had rebuilt `backfill_done.json` in the seconds between the `rm` and the launch. Run manual passes as `flock /tmp/lsbf.lock python3 backfill.py` so they queue behind cron instead of interleaving with it.

### LINKSCAN v3, same day: who actually owns the socials (`/opt/linkscan/socials.py`)
- [stated] Ben, describing a GMGN feature he wants the equivalent of: show how many other tokens point at the same socials. **And the nuance he was explicit about: "it doesn't mean that the first one is the correct one, because sometimes somebody is announcing we're going to have a token soon and then there's a bunch of fakes who are earlier pointing at that Twitter account and then the last one is actually the correct one once they really launch. Once they announce their own token on their Twitter or Telegram, that's when you can then match it."**
- **THE RULE, and it is the opposite of the obvious one: LAUNCH ORDER DECIDES NOTHING.** The squatters get there FIRST precisely because the team announced before it launched, so "earliest wins" systematically picks the scam. The only thing that settles ownership is **ENDORSEMENT: the project's own account or its own site naming a contract address.** `first_seen` is recorded as context and is never an input to the decision.
- **Four states.** `SOLE` (nobody else claims it) · `ENDORSED` (the site or the X account names THIS contract, so every other claimant is a squatter, +2 to the score) · `IMPOSTOR` (they name a DIFFERENT contract as their own, new verdict, never alerts) · `CONTESTED` (several claimants and nobody endorsed yet, which is exactly Ben's "announced but not launched" window, **new verdict, never alerts, because this is the moment you cannot tell**). Plus `SHARED HOST` at 4+ claimants, for github.com / huggingface.co / a launchpad's own domain, where a collision means the "website" is somebody else's platform rather than impersonation.
- `claims.json` is the permanent index, keyed `x:<handle>` and `site:<domain>`, grown on every scan and seeded from 290 existing bench rows (533 keys, 6 genuinely contested). `endorse.json` caches the X lookup for 6h. **The X call only fires when there IS a collision to settle, so the cost is near zero.**
- **VERIFIED ON A LIVE IMPOSTOR.** `uplift.cash` is claimed by two tokens. **UP `0x559b4293e2f372774e1fa6dc0a38a2cef6e04a99`** (mcap $3,419,239, launched 10 Sep 02:10) is named by the site itself, so it resolves **ENDORSED, LOOK, score 9**. **PUP `0x9fdf90d1a1bc656b1c652e3097b68beefc545999`** (mcap $358,385, launched 11 Sep 18:37) is riding the same site and resolves **IMPOSTOR**, correctly silent. Note it also independently tripped the COPY detector on "identical code bundle", so two unrelated checks agreed.
- **THE BUG THAT MATTERED, and the principle is general: a test that finds BOTH sides guilty carries no information.** The first version passed `site_addrs` with the token itself excluded, so the check could see "the site names my rival" but never "the site names me". With two tokens on one site each accused the other and both returned IMPOSTOR. Fixed by passing the UNFILTERED address list and testing three ways: site names me and not them (ENDORSED), names them and not me (IMPOSTOR), names several (settles nothing, fall through to X). **Whenever a discriminator is symmetric, it is not a discriminator.**
- **NEW CHEAP KILLER: the linked X account does not exist at all.** Both zecforge.tech tokens link `@zecforgetech`, and `user/info` returns no data for it. Claiming socials that are not there is a finding, not a missing datapoint, so it flags `X ACCOUNT DOES NOT EXIST` and returns FAKE. `socials.account_missing()` reads the `x_info` result already being fetched, so it costs nothing extra.
- **`x_handle` was manufacturing fake accounts and had been all along.** The regex took the first path segment of any x.com URL, so `x.com/search?q=...` and `x.com/i/communities/...` entered the index as **@search (4 tokens) and @i (2 tokens)**, which would have produced phantom collisions forever. Added an `X_RESERVED` set (search, i, home, explore, intent, share, hashtag, status, notifications, messages, settings, compose, login, signup, about, tos, privacy, download, account, help, topics, lists, communities).
- **Measured twitterapi.io behaviour, and it reverses the call order: `queryType=Latest` returned 0 tweets for EVERY account tested**, including @pairexmarket (38 posts) and @ronolaunch (15 posts). Only `Top` returns anything for accounts this young, and `user/last_tweets` returned 0 in all three cases. So `endorsed()` calls **Top first and Latest only as a fallback**; the earlier order burned a wasted call every single time. This confirms and extends the xdeck v5 finding.
- Collision base rate on the seed data: **3 real handle collisions in 259 handles, 5 domain collisions in 267 domains**, and most domain collisions were shared hosts rather than impersonation. Rare, but each one is either a scam or a real project, so it is worth the near-zero cost.
- Backups: `socials.py.bak-*`/`.b3-*`, `scan.py.socbak-*`/`.b3-*`, `checks.py.sbak-*`.

## 12 Sep 2026: PRM WATCH was silently stalled for 8 hours, and the fix is the reusable part
- Found while re-screening PRM for Ben. I first told him the bot was "broken, throwing DNS errors since 06:14 UTC". **That was wrong and was corrected to him in the same session.** The 06:14 DNS line was a one-off blip at boot; the loop recovered. What the log age actually proved was that the bot logs NOTHING on a quiet cycle, so eight hours of silence is indistinguishable from eight hours of health. **Log age is not evidence of a stall unless the service emits a heartbeat.**
- **THE REAL STALL, found by running the script in the foreground and reading the traceback at SIGINT (`timeout -s INT 40 python3 -u -X faulthandler prmwatch.py`): the process spent its entire life inside `cohort_total`, doing 38 SEQUENTIAL `balanceOf` calls against the public RH RPC, which now 429s hard.** With `tries=6` and escalating sleeps, one sweep took minutes, and `remaining_block` is called on every alert and at startup, so `last_block` never advanced. Diagnostic shortcut worth keeping: `cat /proc/$PID/syscall` showed `7` (poll) with timeout `0xafc8` = 45,000 ms, which is exactly the `http()` timeout, i.e. one hung HTTP request, not a busy loop.
- **FIX, in five patches, all shipped as base64 (a nested heredoc through the Mac's zsh threw `parse error near '}'` again, third time this rule has been earned):**
  1. **`rpc_batch(cfg, calls, chunk=20, tries=5)`**: JSON-RPC array batching, one HTTP round trip per 20 calls. **`cohort_total` went from minutes to 4.6 seconds.** This belongs in every bot in the fleet that sweeps many wallets.
  2. `_CT_CACHE` with a 90s TTL on `cohort_total`, so a burst of alerts reuses one sweep instead of re-sweeping per alert.
  3. `http()` timeout 45s to 12s, `rpc()` tries 6 to 3, backoff in `rpc_batch` raised to 4s per attempt because 429 needs seconds not milliseconds.
  4. `max_block_span` 40000 to **3000** in config.json. A busy token plus the 10,000-log cap made the old span recurse for minutes; 3,000 blocks is 5 minutes of chain against a 20s poll, so it still catches up 15x faster than real time.
  5. **Heartbeat every 900s** (`prmwatch heartbeat block=... cohort_wallets=38`) written to the log, and **prmwatch added to the watchdog `UNITS` table with `max_silence` 2400** (it was missing entirely, which is why nobody noticed). **A silent-by-design bot MUST emit a heartbeat or it cannot be watched, and `max_silence` 0 only checks `systemctl is-active`, which was green the whole time.**
- Regression introduced and caught in one cycle: my rewrite returned `per` as a list of tuples while the rest of the file calls `per.values()` and `per.items()`. **Keep the return contract when replacing a function body.** Now returns a dict, with the auction total added as a synthetic row `"auction bids (fleet, unsettled)"`.
- Backups: `prmwatch.py.bak-20260912-141848`, `-142337`, `-143220`, `-143335`, `-143605`; `config.json.bak-*`; `watchdog.py.bak-20260912-141907`. Verified live 14:38 UTC: `last_block` 61,182,695 against head 61,182,875, i.e. 18 seconds behind.

### The classifier bug the same fix exposed, and it is the important one
- **PRM WATCH would have fired 10 false SELL alerts that morning.** `classify()` used the fleet-wide rule "counterparty has more than 100 bytes of code, therefore SELL". Three cohort wallets (`0x287dcd51d7cc4b0747b7b634a85b154551db6b77`, `0x7cbc725d55d337e490c16560be9622481783c06e`, `0x91bba21186c1f42917e61b031645c1440a82ed94`) sent 5,610,000 PRM to `0xEDC64dCc7d15364006d6ac3e387976CBc4735eeB`, which is the **Premium Desk auction manager**, not a pool. Those were BIDS. The 1,090,000 coming back were outbid REFUNDS, which the same rule would have called BUYs.
- **Patch: named non-pool venues are resolved BY ADDRESS FIRST, code size only after.** New kinds `AUCTION_BID`, `AUCTION_REFUND`, `BURN`. `AUCTION_MGR = 0xedc64dcc7d15364006d6ac3e387976cbc4735eeb`, `DEAD_ADDR = 0x000000000000000000000000000000000000dead`.
- **And the balance sweep now ADDS the auction manager's PRM balance back into the cohort total**, because tokens parked in a live bid have left the wallets but are still fleet-controlled until settlement. Without that the "how much is left" line drops by the bid size and reads as selling.
- **This is the second time in two days that "the counterparty is a contract" produced a wrong SELL** (the first was unistalk-rh calling a PoolManager transfer a "sent to"). **Standing rule for the whole fleet: code size is a fallback, never the first test. Every bot that classifies a transfer needs a named-venue map checked before the bytecode heuristic, and the map must include escrows, lockers, auction and staking contracts, not just pools and routers.**

## 12 Sep 2026: UNIPCS STALKER RH, patch7, the size gate. "These dollar amounts are not relevant to unipcs"
- [stated] Ben pasted three alerts about `0x1f40df557a9fa6eab97f3521c7b67d55e09e0836`: a LOOK on a **$1,221 clip of FLYAI from a wallet funded 2.18 ETH**, then its sell, then its move out. His objection: the amounts are nowhere near unipcs scale.
- **He is right and the cause was an OR in the first-buy gate:** `worth = first and (frac >= 0.2 OR usd >= 5000) and age_h <= 12`. Twenty percent of a tiny funding is still tiny, so the dollar floor never bound. A 2.18 ETH wallet is about $5.6k of total ammunition; his known burners took **3 to 67 ETH** and his Solana clips run 200+ SOL.
- **Patch (`rhstalk.py.bak-20260912-154553`), four changes:** the gate is now an **AND** (`usd >= first_buy_min_usd` AND `frac >= first_buy_min_frac` AND fresh enough); STRONG additionally requires `bb["usd"] >= strong_min_usd` (10000) so three dust clips cannot fire it; the 10th/20th clip milestones only fire on a wallet that was **already alerted**, instead of introducing one that never qualified; and the size floors moved.
- **Thresholds were CALIBRATED against the 46 wallets already in `state_rh.json`, not guessed.** Every wallet Ben saw as noise was funded **2.0 to 2.5 ETH**; every alert worth having came from **3.65 ETH and up**. So `min_eth` 2 -> **3** (his documented floor, keeps the whole real range), `min_usd_relay` 5000 -> **8000**, `fund_alert_min_eth` 5 -> **10**, `min_buy_usd` 300 -> **1000**, `first_buy_min_usd` stays 5000 but is now binding.
- **Replayed the old and new rules over every buy the bot has ever recorded: 18 alerts -> 10.** Kept: $49,984 PONS and $49,934 AI (37.88 ETH), $47,288 INU (10.34), $30,633 wagie (7.98), $23,119 INU (20.02), $14,266 eTokenize (3.65), $9,114 GME, $8,940 STRAT, $7,565 INU, $5,394 DRILL. Killed: the FLYAI pair ($2,165 / $949), the 2.21 ETH five-coin sprayer, the 2.24 ETH four-coin sprayer, $2,201 STRAT, $5,573 DOGGO. **The one uncomfortable edge: the largest thing killed ($5,573) is bigger than the smallest thing kept ($5,394), because the wallet-size cut bites before the clip size. Accepted deliberately: a 2.2 ETH wallet spending 99% of itself is a small trader, not him.**
- **16 burners funded under 3 ETH were deleted from `state_rh.json`** (backup `state_rh.json.bak-20260912-154646`), including the FLYAI one, because `alerted: True` keeps a junk wallet talking through the SOLD and MOVED paths long after the LOOK that introduced it. **When a gate is tightened, purge the state the loose gate created, or the old wallets keep broadcasting.**
- Generalisable: **an OR between an absolute and a relative threshold silently deletes the absolute one.** If a floor is meant to be a floor, it has to be an AND. And the first discriminator should be the size of the actor, not the size of one action.
- Service restarted 15:46 UTC, active, 30 burners tracked.
