#!/usr/bin/env python3 """Independently verify every PONS payout Ponsion has ever made. python3 verify.py No dependencies, no API key, no account, and nothing fetched from ponsion.family. Python 3.8+ and an internet connection. It reads the public chain and checks our arithmetic against it. What this actually proves ------------------------- A distributor contract cannot pick its own winners, but it also cannot compute them. An ERC-20 keeps balances in a mapping, mappings are not enumerable, and nothing on chain records what a balance was an hour ago. So something off chain has to build the list of who gets paid, and for Ponsion that something is our watcher. That is the one place where "unruggable" would otherwise rest on trusting us rather than on code. This script removes the trust. Every input the watcher used is a public event, so a stranger can redo the entire calculation: * Transfer on the token -> every balance, at every block * EpochOpened on the rewarder -> each round's budget and denominator * Distributed on the rewarder -> what each wallet was actually paid * Clamped on the rewarder -> where the contract overrode the watcher It replays the token's whole transfer history, recomputes what each wallet was owed under the published rules, and diffs that against what the chain says was paid. Had we ever quietly favoured a wallet, short-changed anyone, or invented a recipient, the diff below would show it. The rules being checked ----------------------- A round splits its PONS by time-weighted holding: a wallet accrues `balance x blocks_held` over the round and the pot is divided on that. Holding for the full hour earns full weight; buying in at the last minute earns almost none. That is what stops a round being farmed by buying just before the snapshot and selling just after. Two limits then apply. The contract enforces a hard per-wallet ceiling of `budget x balance / eligibleSupply` and clamps anything above it no matter what the watcher asks for, so that ceiling, not the watcher, is the real protection. Payouts at or below 0.001 PONS are skipped because the gas to send them exceeds their value; that PONS is not lost, it stays in the contract and joins the next round's budget. Reading the output ------------------ Expect small differences rather than zeroes, and here is the honest reason. The watcher closes a round at whatever block the chain was on when it ran, then sends openEpoch, which lands a few seconds later. Only the second of those two blocks is visible on chain, so this script's round boundaries sit slightly after the ones the watcher used, and trades inside that sliver fall into a different round here than they did there. Against an hourly round of roughly 36,000 blocks, that is a rounding error. The size of that error is a fixed number of blocks, so it matters more in a short round than a long one, and the table below allows for it proportionally. Round 1 is the visible case: it ran about five minutes rather than an hour, because the contract's epochEnd starts at zero and the first openEpoch could fire straight away, so the same few seconds of drift is a much larger share of it. Gaps that are large, one-sided, or concentrated on particular wallets are not timing, and those are the ones worth catching. The four checks labelled "do not depend on round boundaries" hold regardless of drift, so they are the ones to read first: they are what would catch a planted recipient, a wallet paid above its ceiling, a payout to the pool, or a round spending more than it held. """ import argparse import json import sys import time import urllib.error import urllib.request # Public and independently checkable on the explorer. TOKEN = "0x0eacab9485af589d9ac07f4b0bdc6d4a0a95ce84" DISTRIBUTOR = "0x93d994eecceddaba04ee821e895afd4a92efa8a2" LAUNCH_BLOCK = 49601783 DEFAULT_RPC = "https://rpc.mainnet.chain.robinhood.com" # Event topics. Recompute any of these yourself rather than taking our word: # cast sig-event "Distributed(uint256,address,uint256)" # The script additionally cross-checks each one against the shape of the logs the # contract actually emits, so a wrong constant here would be caught, not trusted. TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" EPOCH_OPENED = "0x2c2fc41e02be140e4eb61e7fe27089f6e3fa3f575521c7e25085c7823887682f" DISTRIBUTED = "0xa7932e9c92f31e1ed56b29d00bbe669a97484dc24de28dd9c8c0429df7f35847" CLAMPED = "0x825b8d626ecae7aaf078e0cd888a61d2eff6abf5c2a430d1774eb0c37379161e" # (indexed topics including topic0, words of unindexed data) for each event above. SHAPES = { EPOCH_OPENED: (2, 2), # epoch indexed; budget, eligibleSupply in data DISTRIBUTED: (3, 1), # epoch, holder indexed; amount in data CLAMPED: (3, 2), # epoch, holder indexed; requested, sent in data } # address public immutable excludedN -> cast sig "excluded0()" EXCLUDED_SELECTORS = [ ("excluded0", "0x327d748d"), ("excluded1", "0xf99d0102"), ("excluded2", "0x406d757e"), ("excluded3", "0xd86c6496"), ("excluded4", "0x094cf3c8"), ] TOTAL_DISTRIBUTED = "0xefca2eed" ZERO = "0x0000000000000000000000000000000000000000" # The watcher's configured floor: payouts at or below this cost more in gas than # they deliver, so they roll into the next round instead. MIN_PAYOUT = 10**15 # 0.001 PONS # How far the boundaries can be out, in blocks. The watcher closes a round at the # chain head, then openEpoch lands a few seconds later, and only that second block # is on chain. At ~100ms per block a few seconds is a few tens of blocks; 200 is # deliberately generous. # # The tolerance has to scale with round length rather than being flat, because the # same absolute drift is a far bigger share of a short round. Ponsion's first # round ran about five minutes (the contract's epochEnd starts at zero, so the # first openEpoch could fire immediately), while every round since has run the # full hour, which is roughly 36,000 blocks. DRIFT_BLOCKS = 200 MIN_TOLERANCE = 0.002 # floor, so an hourly round still gets a little slack class Rpc: def __init__(self, url): self.url = url self.calls = 0 def __call__(self, method, params, timeout=60, tries=4): last = None for attempt in range(tries): try: body = json.dumps( {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}) req = urllib.request.Request( self.url, data=body.encode(), headers={"content-type": "application/json", "User-Agent": "ponsion-verify/1"}) with urllib.request.urlopen(req, timeout=timeout) as r: out = json.load(r) self.calls += 1 if "error" in out: raise RuntimeError(out["error"].get("message", "rpc error")) return out["result"] except Exception as e: last = e time.sleep(1.0 * (attempt + 1)) raise RuntimeError(f"{method} failed: {last}") def logs(self, address, topics, frm, to, depth=0, progress=None): """Fetch logs, halving the range whenever the node refuses it. Public endpoints cap how much history a single query may cover, and this chain produces a block roughly every 100ms, so "since launch" is hundreds of thousands of blocks. Halving adapts to whatever limit this node has without needing to know it. """ try: out = self("eth_getLogs", [{"fromBlock": hex(frm), "toBlock": hex(to), "address": address, "topics": topics}]) if progress: progress(to - frm + 1) return out except Exception: if to - frm < 2 or depth > 26: raise mid = (frm + to) // 2 return (self.logs(address, topics, frm, mid, depth + 1, progress) + self.logs(address, topics, mid + 1, to, depth + 1, progress)) def to_int(h): return int(h, 16) if h and h != "0x" else 0 def addr_from_topic(t): return "0x" + t[-40:].lower() def fmt(wei, dp=4): return f"{wei / 10**18:,.{dp}f}" # --------------------------------------------------------------------- replay # Inlined rather than imported, so the rules you are asked to trust and the code # you are running are the same file. class Ledger: """Balances and time-weights rebuilt from Transfer events alone.""" def __init__(self, start_block): self.balances = {} self.since = {} self.weight = {} self.round_start = start_block def _accrue(self, addr, upto): bal = self.balances.get(addr, 0) start = self.since.get(addr, self.round_start) if bal > 0 and upto > start: self.weight[addr] = self.weight.get(addr, 0) + bal * (upto - start) self.since[addr] = upto def apply(self, frm, to, value, block): # Both sides are credited for the time they held their previous balance # before the balance changes, which is what makes the weight a true # balance-over-time integral rather than a snapshot. self._accrue(frm, block) self._accrue(to, block) if frm != ZERO: self.balances[frm] = self.balances.get(frm, 0) - value if to != ZERO: self.balances[to] = self.balances.get(to, 0) + value def close(self, block): # Holders who never traded during the round would otherwise have no # weight at all, since weight is only credited when a balance changes. for addr in list(self.balances): if self.balances.get(addr, 0) > 0: self._accrue(addr, block) return dict(self.weight) def start(self, block): self.weight = {} self.round_start = block for addr in self.balances: self.since[addr] = block def compute_payouts(weights, balances, budget, eligible_supply, excluded, min_payout=MIN_PAYOUT): """Split `budget` by time-weight, capped by the contract's per-wallet ceiling.""" ex = {a.lower() for a in excluded} | {ZERO} live = {a: w for a, w in weights.items() if w > 0 and a not in ex and balances.get(a, 0) > 0} total_weight = sum(live.values()) if total_weight == 0 or budget == 0 or eligible_supply == 0: return {}, {"capped": 0, "dust": 0} out = {} allocated = 0 capped = dust = 0 for addr, w in sorted(live.items(), key=lambda kv: -kv[1]): want = budget * w // total_weight cap = budget * balances[addr] // eligible_supply if want > cap: want = cap capped += 1 if want <= min_payout: dust += 1 continue if allocated + want > budget: want = budget - allocated if want <= 0: break out[addr] = want allocated += want return out, {"capped": capped, "dust": dust} # ----------------------------------------------------------------------- main def main(): ap = argparse.ArgumentParser( description="Recompute every Ponsion payout from the public chain.") ap.add_argument("--rpc", default=DEFAULT_RPC, help="RPC endpoint to read from") ap.add_argument("--rounds", type=int, default=0, help="check only the most recent N rounds (default: all)") ap.add_argument("--wallet", help="also print this wallet's per-round detail") ap.add_argument("--explain", type=int, default=0, metavar="ROUND", help="dump the per-wallet differences for one round") args = ap.parse_args() rpc = Rpc(args.rpc) t0 = time.time() print() print("Ponsion payout verification") print(f" rpc {args.rpc}") print(f" token {TOKEN}") print(f" distributor {DISTRIBUTOR}") print() head = to_int(rpc("eth_blockNumber", [])) print(f"chain head {head:,}; launched at {LAUNCH_BLOCK:,} " f"({head - LAUNCH_BLOCK:,} blocks of history)") # ---- the exclusion set, read off the contract rather than assumed. # These are immutable, so today's values are the ones that applied to every # past round too. Excluding them is what stops the pool and the bonding curve # from swallowing the pot they are not entitled to. excluded = [DISTRIBUTOR] print("\nexcluded addresses (read from the contract)") for name, sel in EXCLUDED_SELECTORS: try: r = rpc("eth_call", [{"to": DISTRIBUTOR, "data": sel}, "latest"]) a = "0x" + r[-40:].lower() if to_int(a): excluded.append(a) print(f" {name} {a}") except Exception as e: print(f" {name} unreadable ({str(e)[:40]})") # ---- one pass over the rewarder's logs, partitioned by event. print("\nreading the rewarder's events") dlogs = rpc.logs(DISTRIBUTOR, [], LAUNCH_BLOCK, head) by_topic = {} for lg in dlogs: by_topic.setdefault(lg["topics"][0], []).append(lg) # Cross-check the hardcoded topics against the shape of what the contract # really emits, so a wrong constant above surfaces as an error rather than # silently producing an empty, reassuring result. for topic, shape in SHAPES.items(): got = by_topic.get(topic) if not got: continue lg = got[0] actual_shape = (len(lg["topics"]), len(lg["data"][2:]) // 64) if actual_shape != shape: sys.exit(f"event {topic[:10]} has shape {actual_shape}, expected {shape}") opened_logs = by_topic.get(EPOCH_OPENED, []) paid_logs = by_topic.get(DISTRIBUTED, []) clamp_logs = by_topic.get(CLAMPED, []) if not opened_logs or not paid_logs: sys.exit("no rounds or no payouts found; check the distributor address") rounds = [] for lg in opened_logs: data = lg["data"][2:] rounds.append({ "epoch": to_int(lg["topics"][1]), "budget": int(data[0:64], 16), "eligible": int(data[64:128], 16), "block": to_int(lg["blockNumber"]), }) rounds.sort(key=lambda r: r["epoch"]) actual = {} for lg in paid_logs: e = to_int(lg["topics"][1]) who = addr_from_topic(lg["topics"][2]) actual.setdefault(e, {}) actual[e][who] = actual[e].get(who, 0) + to_int(lg["data"]) total_actual = sum(sum(v.values()) for v in actual.values()) print(f" {len(rounds)} rounds (epochs {rounds[0]['epoch']}" f"..{rounds[-1]['epoch']})") print(f" {len(paid_logs):,} payouts totalling {fmt(total_actual)} PONS") print(f" {len(clamp_logs):,} clamps, where the contract overrode the watcher") # The contract's own running total is an independent check on our log sum. try: on_chain_total = to_int( rpc("eth_call", [{"to": DISTRIBUTOR, "data": TOTAL_DISTRIBUTED}, "latest"])) delta = abs(on_chain_total - total_actual) state = "matches" if delta <= 10**12 else f"DIFFERS by {fmt(delta)}" print(f" contract's totalDistributed: {fmt(on_chain_total)} PONS ({state})") except Exception: on_chain_total = None # ---- the whole transfer history last_block = rounds[-1]["block"] span = last_block - LAUNCH_BLOCK + 1 print(f"\nreplaying the token's transfer history, " f"{LAUNCH_BLOCK:,}..{last_block:,} ({span:,} blocks)") print(" this is the slow part: it is every transfer the token has ever had") done = [0] last_pct = [-1] def progress(n): done[0] += n pct = int(100 * done[0] / span) if pct != last_pct[0] and pct % 10 == 0: last_pct[0] = pct print(f" {pct}%", flush=True) tlogs = rpc.logs(TOKEN, [TRANSFER], LAUNCH_BLOCK, last_block, progress=progress) transfers = [(addr_from_topic(lg["topics"][1]), addr_from_topic(lg["topics"][2]), to_int(lg["data"]), to_int(lg["blockNumber"]), to_int(lg["logIndex"])) for lg in tlogs] transfers.sort(key=lambda t: (t[3], t[4])) print(f" {len(transfers):,} transfers") # ---- walk the history, closing a round at each EpochOpened block wanted = {r["epoch"] for r in (rounds[-args.rounds:] if args.rounds else rounds)} led = Ledger(LAUNCH_BLOCK - 1) i = 0 results = [] wallet = (args.wallet or "").lower() or None wallet_rows = [] excl_set = {a.lower() for a in excluded} | {ZERO} prev_boundary = LAUNCH_BLOCK - 1 for r in rounds: boundary = r["block"] while i < len(transfers) and transfers[i][3] <= boundary: frm, to, val, blk, _ = transfers[i] led.apply(frm, to, val, blk) i += 1 weights = led.close(boundary) balances = dict(led.balances) if r["epoch"] in wanted: expected, summary = compute_payouts( weights, balances, r["budget"], r["eligible"], excluded) got = actual.get(r["epoch"], {}) everyone = set(expected) | set(got) diffs = [(a, expected.get(a, 0), got.get(a, 0)) for a in everyone] worst = max(diffs, key=lambda d: abs(d[1] - d[2])) if diffs else None # Paid but with no computable claim at all. This is the shape a # planted recipient would take. ghosts = [a for a in got if a not in expected and got[a] > MIN_PAYOUT] # Above the ceiling the contract itself enforces. over_cap = [] for a, amt in got.items(): cap = (r["budget"] * balances.get(a, 0) // r["eligible"] if r["eligible"] else 0) if amt > cap + 1: over_cap.append((a, amt, cap)) results.append({ "epoch": r["epoch"], "budget": r["budget"], "block": r["block"], "span": max(1, boundary - prev_boundary), "exp_total": sum(expected.values()), "got_total": sum(got.values()), "exp_n": len(expected), "got_n": len(got), "worst": worst, "ghosts": ghosts, "over_cap": over_cap, "paid_excluded": [a for a in got if a in excl_set], "capped": summary["capped"], "dust": summary["dust"], }) if args.explain == r["epoch"]: print(f"\nper-wallet differences for round {r['epoch']}") print(f" budget {fmt(r['budget'])} PONS, " f"eligible supply {fmt(r['eligible'], 0)} PONSION, " f"boundary block {r['block']:,}") rows = sorted(diffs, key=lambda d: -abs(d[1] - d[2])) print(f" {'wallet':<44}{'recomputed':>13}{'paid':>13}{'gap':>13}") for a, exp, act in rows[:25]: if exp == act: continue print(f" {a:<44}{fmt(exp):>13}{fmt(act):>13}" f"{fmt(exp - act):>13}") only_exp = [(a, e) for a, e, g in diffs if g == 0 and e > 0] only_got = [(a, g) for a, e, g in diffs if e == 0 and g > 0] if only_exp: print(f" {len(only_exp)} wallet(s) recomputed but not paid, " f"totalling {fmt(sum(v for _, v in only_exp))} PONS") near_dust = [v for _, v in only_exp if v < 5 * MIN_PAYOUT] print(f" of which {len(near_dust)} are within 5x the " f"{fmt(MIN_PAYOUT)} dust floor") if only_got: print(f" {len(only_got)} wallet(s) paid but not recomputed, " f"totalling {fmt(sum(v for _, v in only_got))} PONS") print() if wallet: wallet_rows.append({ "epoch": r["epoch"], "balance": balances.get(wallet, 0), "expected": expected.get(wallet, 0), "actual": got.get(wallet, 0), }) led.start(boundary) prev_boundary = boundary # ------------------------------------------------------------------ report print() print("=" * 79) print(f"{'round':>5} {'mins':>5} {'budget':>12} {'paid':>12} " f"{'recomputed':>12} {'wallets':>11} {'gap':>7} {'ok?':>5}") print("-" * 79) flagged = [] for r in results: gap = abs(r["exp_total"] - r["got_total"]) rel = gap / r["budget"] if r["budget"] else 0 # Allowed drift shrinks as the round gets longer, because the boundary # error is a roughly fixed number of blocks either way. tol = max(DRIFT_BLOCKS / r["span"], MIN_TOLERANCE) mark = "ok" if rel > tol: mark = "*" flagged.append(r) if r["over_cap"] or r["paid_excluded"] or r["ghosts"]: mark = "FAIL" if r not in flagged: flagged.append(r) mins = r["span"] / 600 # ~100ms blocks, so 600 blocks to the minute print(f"{r['epoch']:>5} {mins:>5.0f} {fmt(r['budget']):>12} " f"{fmt(r['got_total']):>12} {fmt(r['exp_total']):>12} " f"{r['got_n']:>5}/{r['exp_n']:<5} {100 * rel:>6.2f}% {mark:>5}") print("=" * 79) print("gap is the difference between what was paid and what this script") print("recomputed, as a share of the round's budget. Allowed drift scales with") print("round length: a few seconds of boundary error matters more in a short") print("round than in a full hour.") # ---- invariants that hold regardless of boundary drift print("\nchecks that do not depend on round boundaries") all_over = [(r["epoch"], x) for r in results for x in r["over_cap"]] if all_over: print(f" FAIL {len(all_over)} payout(s) exceeded the on-chain cap") for e, (a, amt, cap) in all_over[:5]: print(f" round {e}: {a} got {fmt(amt)}, cap {fmt(cap)}") else: print(" PASS no wallet was ever paid above its on-chain cap") all_excl = [(r["epoch"], a) for r in results for a in r["paid_excluded"]] if all_excl: print(f" FAIL {len(all_excl)} payout(s) went to an excluded address") for e, a in all_excl[:5]: print(f" round {e}: {a}") else: print(" PASS no excluded address (pool, curve, rewarder) was ever paid") ghosts = [(r["epoch"], a) for r in results for a in r["ghosts"]] if ghosts: print(f" FAIL {len(ghosts)} wallet(s) paid with no time-weighted claim") for e, a in ghosts[:5]: print(f" round {e}: {a}") else: print(" PASS every wallet paid held the token and had a real claim") overspent = [r for r in results if r["got_total"] > r["budget"] + 10**12] if overspent: print(f" FAIL {len(overspent)} round(s) paid out more than the budget") else: print(" PASS no round paid out more PONS than it held") # ---- the wallet view if wallet: print(f"\nper-round detail for {wallet}") print(f"{'round':>5} {'held (PONSION)':>18} {'expected':>13} {'actual':>13}") shown = 0 for row in wallet_rows: if not (row["balance"] or row["actual"] or row["expected"]): continue print(f"{row['epoch']:>5} {fmt(row['balance'], 0):>18} " f"{fmt(row['expected']):>13} {fmt(row['actual']):>13}") shown += 1 if not shown: print(" this wallet has never held PONSION during a round") else: got_sum = sum(r["actual"] for r in wallet_rows) exp_sum = sum(r["expected"] for r in wallet_rows) print(f" received {fmt(got_sum)} PONS; recomputed {fmt(exp_sum)}") # ---- verdict checked_paid = sum(r["got_total"] for r in results) checked_exp = sum(r["exp_total"] for r in results) drift = abs(checked_paid - checked_exp) denom = checked_exp or 1 print() print(f"rounds checked {len(results)}") print(f"PONS actually paid {fmt(checked_paid)}") print(f"PONS recomputed {fmt(checked_exp)}") print(f"difference {fmt(drift)} ({100 * drift / denom:.3f}%)") print(f"rpc calls {rpc.calls} in {time.time() - t0:.0f}s") # The drift has a direction, and saying so is more useful than leaving the # reader to notice it and wonder. Because this script's boundaries always sit # slightly after the watcher's, and cap-limited payouts depend on the balance # at the boundary, a growing holder base makes the recomputed figure the # higher of the two. Worth stating plainly that the bias runs against us: # it never implies we paid out more than the rules allowed. if checked_exp > checked_paid: print() print("The recomputation comes out slightly above what was paid, in every") print("round. That is the expected direction: these boundaries sit a few") print("blocks later than the watcher's, and a later boundary catches a few") print("more tokens in a growing holder base, which lifts the caps. The bias") print("runs against us, never in our favour.") print() hard_fail = all_over or all_excl or ghosts or overspent if hard_fail: print("VERDICT: FAILED. At least one payout broke a rule that timing") print("cannot explain. Details above. Please make this public.") return 2 if flagged: print(f"VERDICT: {len(flagged)} round(s) drifted more than boundary error") print("explains (marked * above). No rule was broken, but those rounds are") print(f"worth a look: re-run with --explain {flagged[0]['epoch']} for the") print("per-wallet detail.") return 1 print("VERDICT: PASSED. Every round matches an independent recomputation to") print("within boundary drift; no wallet was paid above its on-chain cap; no") print("excluded address was paid; no round overspent its budget.") return 0 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print("\ninterrupted") sys.exit(130)