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>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user