09e9d469dc
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>
92 lines
4.3 KiB
Markdown
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
|
|
```
|