#!/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)