diff --git a/tools/server/README.md b/tools/server/README.md
new file mode 100644
index 0000000..85252cf
--- /dev/null
+++ b/tools/server/README.md
@@ -0,0 +1,91 @@
+# 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
+```
diff --git a/tools/server/cron.d-rmc-depth-watch b/tools/server/cron.d-rmc-depth-watch
new file mode 100644
index 0000000..909bf07
--- /dev/null
+++ b/tools/server/cron.d-rmc-depth-watch
@@ -0,0 +1,9 @@
+# RM Circle: ping Marty when an organisation outgrows what its apex level can collect from.
+# A member at depth d pays the apex on their upgrade to level d+1, only if apex.level >= d.
+# Depth 7 is a hard ceiling (levelIndex tops out at 6), so deeper is not a miss.
+# NOTE: Debian cron IGNORES CRON_TZ. Schedule in UTC. 09:15 UTC = 4:15 AM CDT.
+# Only speaks when the verdict changes, so a steady state stays silent.
+0 */2 * * * root APEX=1154 /usr/bin/python3 /root/rmc-depth-watch.py >> /var/log/rmc-depth-watch.log 2>&1
+15 9 * * * root APEX=139 /usr/bin/python3 /root/rmc-depth-watch.py >> /var/log/rmc-depth-watch.log 2>&1
+20 9 * * * root APEX=137 /usr/bin/python3 /root/rmc-depth-watch.py >> /var/log/rmc-depth-watch.log 2>&1
+25 9 * * * root APEX=21 /usr/bin/python3 /root/rmc-depth-watch.py >> /var/log/rmc-depth-watch.log 2>&1
diff --git a/tools/server/cron.d-rmc-member-watch b/tools/server/cron.d-rmc-member-watch
new file mode 100644
index 0000000..81ceae5
--- /dev/null
+++ b/tools/server/cron.d-rmc-member-watch
@@ -0,0 +1,7 @@
+# RM Circle: watch position #220 and say who catches the money when they upgrade.
+# Marty asked for this on 24 Sep 2026: #220 sits at Apex, and a level-6 (Fastigium) upgrade
+# skips the four uplines between them and #30, so Orlando should catch 9,942.44 POL. The
+# watcher reports what the CONTRACT DID, not what that walk predicted.
+# Every 10 min: ~285 Polygon blocks per run, two RPC calls. Silent unless #220 moves.
+# It never advances its block pointer on a failed scan, and says so if it goes blind.
+*/10 * * * * root WATCH=220 /usr/bin/python3 /root/rmc-member-watch.py >> /var/log/rmc-member-watch.log 2>&1
diff --git a/tools/server/rmc-depth-watch.py b/tools/server/rmc-depth-watch.py
new file mode 100644
index 0000000..b830fc2
--- /dev/null
+++ b/tools/server/rmc-depth-watch.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""RM Circle: warn when an organisation grows deeper than its apex can collect from.
+
+The rule is read off the contract's own _payUpline, not inferred: a member at depth d under
+the apex 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 than 7 pays the apex at any level, so that is not a miss and must not alert.
+
+Pings Marty's phone only when the verdict changes, so a steady state stays silent.
+
+ APEX=1154 python3 depth_watch.py # normal
+ DRY=1 APEX=21 python3 depth_watch.py # print, never send, never write state
+"""
+import json
+import os
+import pathlib
+import sys
+import urllib.request
+
+CONTRACT = "0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf"
+RPCS = ["https://polygon-bor-rpc.publicnode.com", "https://1rpc.io/matic"]
+APEX = int(os.environ.get("APEX", "1154"))
+DRY = os.environ.get("DRY") == "1"
+CEILING = 7 # levelIndex tops out at 6, so depth 7 is the last that can pay an apex
+MAX_WALK = 16 # MAX_MATRIX_DEPTH in the contract
+STATE = pathlib.Path(os.environ.get("STATE_FILE", f"/root/.rmc-depth-watch-{APEX}.json"))
+TOKEN_FILE = pathlib.Path(os.environ.get("TG_TOKEN_FILE", "/root/.mbhermes-telegram-token"))
+CHAT_ID = os.environ.get("TG_CHAT", "1289244227")
+
+
+def call(data):
+ body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "eth_call",
+ "params": [{"to": CONTRACT, "data": data}, "latest"]}).encode()
+ last = None
+ for url in RPCS:
+ try:
+ # publicnode 403s the default Python-urllib agent; the Node client gets through
+ # because it sends one at all, so send a plain identifiable agent of our own.
+ req = urllib.request.Request(url, data=body, headers={
+ "Content-Type": "application/json", "User-Agent": "rmc-depth-watch/1.0"})
+ out = json.load(urllib.request.urlopen(req, timeout=30))
+ if "error" in out:
+ raise RuntimeError(out["error"].get("message", "rpc error"))
+ return out["result"]
+ except Exception as exc: # try the next endpoint before giving up
+ last = exc
+ raise last
+
+
+def enc(n):
+ return format(int(n), "064x")
+
+
+def word(res, i):
+ return int(res[2 + i * 64: 2 + (i + 1) * 64], 16)
+
+
+def member(mid):
+ r = call("0xc92463fa" + enc(mid))
+ return {"id": mid, "level": word(r, 5), "directCount": word(r, 6)}
+
+
+def children(mid):
+ """getMatrixChildren returns a FIXED-size array, inlined: read the words straight off.
+ Decoding it as a dynamic array (offset + length) yields garbage."""
+ r = call("0x04c8cc3d" + enc(mid))
+ n = (len(r) - 2) // 64
+ return [w for w in (word(r, i) for i in range(n)) if w > 0]
+
+
+def send(text):
+ token = TOKEN_FILE.read_text().strip()
+ body = json.dumps({"chat_id": CHAT_ID, "text": text}).encode()
+ req = urllib.request.Request(f"https://api.telegram.org/bot{token}/sendMessage",
+ data=body, headers={"Content-Type": "application/json"})
+ urllib.request.urlopen(req, timeout=30).read()
+
+
+def main():
+ # breadth-first: one call per member, not one per path
+ depth, frontier, seen, count = 0, [APEX], {APEX}, 0
+ while frontier and depth < MAX_WALK:
+ nxt = []
+ for mid in frontier:
+ for c in children(mid):
+ if c not in seen:
+ seen.add(c)
+ nxt.append(c)
+ count += 1
+ if not nxt:
+ break
+ frontier, depth = nxt, depth + 1
+
+ apex = member(APEX)
+ covered = min(apex["level"], CEILING)
+ reachable = min(depth, CEILING)
+ missing = list(range(covered + 1, reachable + 1))
+ behind = bool(missing)
+ at_edge = (not behind) and reachable == covered and covered < CEILING and depth > 0
+ nxt_rung = apex["level"] + 1
+
+ line = f'#{APEX} level {apex["level"]}, {count} in the org, {depth} deep'
+ msg = None
+ if behind:
+ listed = (", ".join(str(d) for d in missing[:-1]) + " and " + str(missing[-1])
+ if len(missing) > 1 else str(missing[0]))
+ msg = (f"RM Circle: #{APEX} is missing generations.\n{line}.\n"
+ f"Depth {listed} upgrade{'s' if len(missing) > 1 else ''} route past you. "
+ f"Level {nxt_rung} picks up depth {covered + 1}"
+ + (", and each rung after that adds one more generation." if len(missing) > 1 else ".")
+ + ("\nDepth 8 and below never pays an apex at any level, so that part is not a miss."
+ if depth > CEILING else ""))
+ elif at_edge:
+ msg = (f"RM Circle: #{APEX} is at its edge.\n{line}.\n"
+ f"One more generation and that depth's upgrade routes past you. "
+ f"Level {nxt_rung} covers it.")
+
+ key = f"{reachable}:{covered}:{'behind' if behind else 'edge' if at_edge else 'ok'}"
+ prev = {}
+ try:
+ prev = json.loads(STATE.read_text())
+ except Exception:
+ pass
+ changed = prev.get("key") != key
+
+ verdict = ("missing depth " + ", ".join(str(d) for d in missing)) if behind else \
+ ("at the edge" if at_edge else "covered")
+ print(f"{line} -> {verdict} ({'changed' if changed else 'unchanged'})", flush=True)
+
+ if msg and changed and not DRY:
+ send(msg)
+ print("telegram: sent", flush=True)
+ elif msg:
+ print(("would send" if DRY else "nothing new to say") + ":\n" + msg, flush=True)
+
+ if not DRY:
+ STATE.write_text(json.dumps({"key": key, "depth": depth, "level": apex["level"],
+ "count": count}))
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as exc:
+ print(f"depth_watch failed: {exc}", file=sys.stderr)
+ sys.exit(1)
diff --git a/tools/server/rmc-member-watch.py b/tools/server/rmc-member-watch.py
new file mode 100644
index 0000000..293365d
--- /dev/null
+++ b/tools/server/rmc-member-watch.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""RM Circle: tell Marty the moment a watched position upgrades, and who caught the money.
+
+Built for one question he asked on 24 September 2026: when #220 buys Fastigium, does Orlando's
+#30 actually get paid? The chain said it should -- #220 sits at Apex with four uplines between
+them and #30, and a level-6 upgrade skips exactly those four -- but "should" is not "did", so
+this reports what the CONTRACT DID, never what the walk predicted. If somebody else catches it,
+that is what the message says.
+
+ 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
+
+Two rules this thing lives by:
+
+ 1. It NEVER advances its block pointer on a failed scan. A watchdog that quietly skips the
+ blocks it could not read is worse than no watchdog: the event passes, nothing is said, and
+ everyone assumes silence means nothing happened.
+ 2. If it cannot reach the chain several runs running, it SAYS SO. Going blind is itself news.
+
+ WATCH=220 python3 rmc-member-watch.py # normal (cron)
+ DRY=1 WATCH=220 python3 rmc-member-watch.py # print only, no send, no state write
+ BACK=50000 DRY=1 WATCH=139 python3 ... # rehearse on history instead of the pointer
+"""
+import json
+import os
+import pathlib
+import sys
+import time
+import urllib.request
+
+CONTRACT = "0x33bdaeefd6d17d80ae53816c916dfb26c4fb2daf"
+# (url, the widest block span that endpoint accepts in one eth_getLogs). All four were measured
+# from core itself, not assumed: polygon-rpc.com, ankr, blastapi and blockpi all refuse it outright.
+# The order matters -- publicnode and tenderly take the whole window in one request, so a normal
+# run costs two calls; the 50-block pair are chunked fallbacks for when those rate-limit. They do:
+# the RM Circle app already polls publicnode from this same IP, so 429s here are routine, not alarming.
+RPCS = [("https://polygon-bor-rpc.publicnode.com", 2000),
+ ("https://polygon.gateway.tenderly.co", 2000),
+ ("https://polygon.drpc.org", 50),
+ ("https://1rpc.io/matic", 50)]
+ROUNDS = 2 # a whole pass over every endpoint, twice, before calling a run failed
+ROUND_WAIT = 20 # seconds between those passes, to let a rate limit lapse
+T_UPGRADED = "0xc0b79a9e133d4dcbb1a606a57591d98dce93c7d5c86197a5caae22c4a1480049"
+T_UPLINE = "0x6cdacf6757bdea1fcf01918794f79ca1e71b088fbec8fde7b027a963cfab0ae9"
+LEVELS = ['Scintilla', 'Ascensus', 'Fabrica', 'Culmen', 'Apex', 'Fastigium', 'Vertex', 'Corona']
+NAMED = {30: 'Orlando'}
+
+WATCH = int(os.environ.get("WATCH", "220"))
+DRY = os.environ.get("DRY") == "1"
+BACK = int(os.environ.get("BACK", "0"))
+STATE = pathlib.Path(os.environ.get("STATE_FILE", "/root/.rmc-member-watch-%d.json" % WATCH))
+TOKEN_FILE = pathlib.Path(os.environ.get("TG_TOKEN_FILE", "/root/.mbhermes-telegram-token"))
+CHAT_ID = os.environ.get("TG_CHAT", "1289244227")
+WARN = "\u26a0\ufe0f"
+BLIND_AFTER = 4 # consecutive failed runs before we admit we have gone blind
+
+
+def rpc(url, method, params, timeout=45):
+ body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode()
+ # publicnode 403s the default Python-urllib agent, so always send one of our own
+ req = urllib.request.Request(url, data=body, headers={
+ "Content-Type": "application/json", "User-Agent": "rmc-member-watch/1.0"})
+ out = json.load(urllib.request.urlopen(req, timeout=timeout))
+ if "error" in out:
+ raise RuntimeError(out["error"].get("message", "rpc error"))
+ return out["result"]
+
+
+def head():
+ last = None
+ for attempt in range(ROUNDS):
+ for url, _ in RPCS:
+ try:
+ return int(rpc(url, "eth_blockNumber", []), 16), url
+ except Exception as exc:
+ last = exc
+ if attempt + 1 < ROUNDS:
+ time.sleep(ROUND_WAIT)
+ raise last
+
+
+def get_logs(lo, hi):
+ """Every log for the watched member between two blocks, from whichever endpoint answers.
+ Chunked to each endpoint's own range limit, so a 50-block-max fallback still works."""
+ pad = "0x" + format(WATCH, "064x")
+ filters = [
+ {"topics": [T_UPGRADED, pad]}, # the watched member upgrading
+ {"topics": [T_UPLINE, None, pad]}, # what that upgrade paid, and to whom
+ ]
+ last = None
+ for attempt in range(ROUNDS):
+ for url, span in RPCS:
+ try:
+ out = []
+ for f in filters:
+ start = lo
+ while start <= hi:
+ end = min(start + span - 1, hi)
+ q = dict(f)
+ q.update({"address": CONTRACT, "fromBlock": hex(start), "toBlock": hex(end)})
+ out += rpc(url, "eth_getLogs", [q])
+ start = end + 1
+ return out, url
+ except Exception as exc:
+ last = exc # start over on the next endpoint
+ if attempt + 1 < ROUNDS:
+ time.sleep(ROUND_WAIT) # every endpoint balked; give the limits a rest
+ raise last
+
+
+def send(text):
+ token = TOKEN_FILE.read_text().strip()
+ body = json.dumps({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML",
+ "disable_web_page_preview": True}).encode()
+ req = urllib.request.Request("https://api.telegram.org/bot%s/sendMessage" % token,
+ data=body, headers={"Content-Type": "application/json"})
+ urllib.request.urlopen(req, timeout=30).read()
+
+
+def lvname(n):
+ return LEVELS[n - 1] if 1 <= n <= len(LEVELS) else "level %d" % n
+
+
+def who(mid):
+ return "#%d (%s)" % (mid, NAMED[mid]) if mid in NAMED else "#%d" % mid
+
+
+def load():
+ try:
+ return json.loads(STATE.read_text())
+ except Exception:
+ return {}
+
+
+def compose(tx, upgrades, pays):
+ """One message per transaction: what was bought, and where every POL of it went."""
+ lines = []
+ for lv in sorted(upgrades):
+ lines.append("#%d upgraded to %s (level %d)." % (WATCH, lvname(lv), lv))
+ if not upgrades:
+ lines.append("#%d generated a pass-up payment." % WATCH)
+ for to, pol in pays:
+ lines.append("\n%s POL went to %s." % (format(pol, ",.2f"), who(to)))
+ if not pays:
+ lines.append("\nNo pass-up payment was logged against it, so it fell through to the "
+ "admin address: nobody upline qualified.")
+ lines.append("\nhttps://polygonscan.com/tx/" + tx)
+ return "\n".join(lines)
+
+
+def stumble(st, exc, where):
+ """One place for every way a run can fail to read the chain.
+
+ Whatever broke -- the block number or the log scan -- the pointer stays put and the failure
+ is COUNTED. An earlier version only counted failures from the log scan, so a total outage
+ (where even the block number is unreachable) raised straight past the counter: the one kind
+ of outage worth shouting about was the one that would have stayed silent.
+ """
+ fails = int(st.get("fails", 0)) + 1
+ print("%s FAILED (%d in a row): %s" % (where, fails, exc), file=sys.stderr)
+ if fails >= BLIND_AFTER and not st.get("blindAlerted") and not DRY:
+ try:
+ send(WARN + " The #%d watchdog cannot reach the chain.\n\n"
+ "%d runs in a row have failed, so nothing past block %s has been read and an "
+ "upgrade could pass unnoticed. Last error: %s"
+ % (WATCH, fails, st.get("lastBlock", "?"), exc))
+ st["blindAlerted"] = True
+ except Exception as e2:
+ # Telegram is unreachable too; log it and let the next run try again
+ print("could not send the blind alert: %s" % e2, file=sys.stderr)
+ st["fails"] = fails
+ if not DRY:
+ STATE.write_text(json.dumps(st))
+ sys.exit(1)
+
+
+def main():
+ st = load()
+ try:
+ bn, _ = head()
+ except Exception as exc:
+ stumble(st, exc, "block number")
+
+ if BACK:
+ lo, hi = bn - BACK, bn
+ elif "lastBlock" in st:
+ lo, hi = st["lastBlock"] + 1, bn
+ else:
+ # first run: a short look back covers the gap between writing this and the first tick
+ lo, hi = bn - 1000, bn
+ if lo > hi:
+ print("nothing new (head %d)" % bn, flush=True)
+ return
+
+ try:
+ logs, src = get_logs(lo, hi)
+ except Exception as exc:
+ stumble(st, exc, "scan %d-%d" % (lo, hi))
+
+ # group by transaction so the upgrade and the payment it caused arrive as one message
+ txs = {}
+ for l in logs:
+ tx = l["transactionHash"]
+ d = txs.setdefault(tx, {"block": int(l["blockNumber"], 16), "upgrades": [], "pays": []})
+ data = l["data"]
+ word = lambda i: int(data[2 + i * 64: 2 + (i + 1) * 64], 16)
+ if l["topics"][0] == T_UPGRADED:
+ d["upgrades"].append(word(0))
+ else:
+ d["pays"].append((int(l["topics"][1], 16), word(1) / 1e18))
+
+ seen = set(st.get("seen", []))
+ order = sorted(txs.items(), key=lambda kv: kv[1]["block"])
+ fresh = [(t, d) for t, d in order if t not in seen]
+ print("scanned %d-%d via %s: %d log(s), %d tx, %d new"
+ % (lo, hi, src, len(logs), len(txs), len(fresh)), flush=True)
+
+ for tx, d in fresh:
+ msg = compose(tx, d["upgrades"], d["pays"])
+ if DRY:
+ print("--- would send ---\n" + msg, flush=True)
+ else:
+ send(msg)
+ print("telegram: sent for " + tx, flush=True)
+ seen.add(tx)
+
+ if not DRY:
+ st.update({"lastBlock": hi, "seen": sorted(seen)[-200:], "fails": 0,
+ "blindAlerted": False, "at": int(time.time())})
+ STATE.write_text(json.dumps(st))
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except SystemExit:
+ raise
+ except Exception as exc:
+ print("member_watch failed: %s" % exc, file=sys.stderr)
+ sys.exit(1)