Files
rm-circle-team-router/tools/server/README.md
T
martbost 09e9d469dc Watch #220, and say who actually caught the money
Marty asked for a watchdog on position #220: tell him when they buy their
upgrade and whether Orlando's #30 gets paid.

The chain says it should. #220 sits at Apex with four uplines between them
and #30, and a level-6 upgrade skips exactly those four, so #30 is the first
candidate the contract tests and it passes both gates (level 5 > 4, three
directs). That is a prediction, though, and the watcher does not report
predictions: it reads MemberUpgraded and UplineRewarded off the logs and says
who was actually paid and how much. If somebody else catches it, it says so.

Tested by replaying real history (BACK=600 over a live #319 upgrade) on both
a wide-range endpoint and a chunked 50-block one, then the dedupe guard, then
two outage cases.

The outage cases mattered. A watchdog that dies quietly is worse than no
watchdog, because silence gets read as "nothing happened", so:

  - a failed scan never advances the block pointer; unread blocks are read
    on the next run rather than skipped
  - four consecutive failures sends a warning instead of going quiet, and
    both failure paths feed one counter. The first version only counted the
    log scan, so a total outage -- where even the block number is unreachable
    -- raised straight past the counter, and the one outage most worth
    shouting about was the one that would have stayed silent.

Also brings rmc-depth-watch.py into the repo. Both watchers existed only on
core, with no copy anywhere, so a rebuilt box lost them silently. The README
records the restore steps and which Polygon RPCs actually serve eth_getLogs
(publicnode and tenderly take 2000 blocks; drpc and 1rpc cap at 50; four
others refuse outright), measured from core rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:25:27 -05:00

92 lines
4.3 KiB
Markdown

# Chain watchers (they run on core, not in the app)
Two standalone scripts that read the RM Circle contract and message Marty's Hermes chat.
They are deliberately **not** part of the site: they need no database, no config volume and
no deploy, and they must keep working even when the app is down. They live at `/root/` on
core (`ssh root@coolify.saasy.top`) with their cron entries in `/etc/cron.d/`. Copies are
kept here so a rebuilt box can be put straight back.
| file | on the server |
|---|---|
| `rmc-depth-watch.py` | `/root/rmc-depth-watch.py` |
| `cron.d-rmc-depth-watch` | `/etc/cron.d/rmc-depth-watch` |
| `rmc-member-watch.py` | `/root/rmc-member-watch.py` |
| `cron.d-rmc-member-watch` | `/etc/cron.d/rmc-member-watch` |
Restoring one:
```sh
scp tools/server/rmc-member-watch.py root@coolify.saasy.top:/root/rmc-member-watch.py
ssh root@coolify.saasy.top 'chmod +x /root/rmc-member-watch.py'
scp tools/server/cron.d-rmc-member-watch root@coolify.saasy.top:/etc/cron.d/rmc-member-watch
ssh root@coolify.saasy.top 'chmod 644 /etc/cron.d/rmc-member-watch'
```
Both read the Telegram token from `/root/.mbhermes-telegram-token` (never in this repo) and
keep their own state file under `/root/.rmc-*.json`. Debian cron **ignores `CRON_TZ`**, so
every schedule in those files is UTC.
## rmc-depth-watch.py
Warns when an organisation grows deeper than its apex's level can collect from. A member at
depth *d* pays the apex on their upgrade to level *d+1*, and only when `apex.level >= d`.
Upgrades stop at level 8, so `levelIndex` never exceeds 6 and **depth 7 is a hard ceiling** —
nothing deeper pays an apex at any level, so that is not a miss and must never alert.
Speaks only when the verdict changes, so a steady state stays silent.
```sh
APEX=1154 python3 rmc-depth-watch.py # normal
DRY=1 APEX=21 python3 rmc-depth-watch.py # print, never send, never write state
```
## rmc-member-watch.py
Watches one position and reports every upgrade it makes together with who caught the
pass-up payment and how much. Added 24 September 2026 for `WATCH=220`: #220 sits at Apex,
and a Fastigium upgrade skips the four uplines between them and #30, so Orlando's #30
should catch 9,942.44 POL. The message reports **what the contract did**, read off the
logs — if somebody else catches it, that is what it says.
```sh
WATCH=220 python3 rmc-member-watch.py # normal (cron, every 10 min)
DRY=1 WATCH=220 python3 rmc-member-watch.py # print only, no send, no state write
DRY=1 WATCH=319 BACK=600 python3 rmc-member-watch.py # rehearse against real history
```
`BACK` is how it was tested before going live: replayed over a real #319 upgrade, on both a
wide-range endpoint and a chunked 50-block one.
## Two rules both scripts follow
1. **Never advance the block pointer on a failed scan.** A watchdog that quietly skips the
blocks it could not read is worse than none: the event passes, nothing is said, and
silence gets read as "nothing happened".
2. **Going blind is itself news.** Four consecutive failures sends a warning. Both failure
paths — the block number and the log scan — feed one counter, because an earlier version
counted only the log scan, and a total outage raised straight past it: the single outage
most worth shouting about was the one that would have stayed silent.
## Polygon RPCs, measured from core rather than assumed
| endpoint | `eth_getLogs` |
|---|---|
| `polygon-bor-rpc.publicnode.com` | 2000-block span |
| `polygon.gateway.tenderly.co` | 2000-block span |
| `polygon.drpc.org` | **50 blocks max** |
| `1rpc.io/matic` | **50 blocks max** |
| polygon-rpc.com, ankr, blastapi, blockpi | refuse (401 / 401 / 403 / 521) |
Any client must chunk to the endpoint's own limit. **publicnode rate-limits core's IP
routinely**, because the site already polls it from the same address, so a 429 there is
ordinary rather than a fault — the watcher walks the whole endpoint list twice, 20 seconds
apart, before it will call a run failed. All of these also need a `User-Agent` header:
publicnode 403s the default Python-urllib one.
Event shapes, verified against live logs:
```
MemberUpgraded(uint48 id, uint8 newLevel, ...) topics[1]=id, data[0]=new level
UplineRewarded(uint48 to, uint48 from, uint8, uint256) topics[1]=to, topics[2]=from,
data[0]=level, data[1]=amount wei
```