#!/usr/bin/env python3
"""league_watch.py — ARION league-desk inflow scanner (eip155:8453 Base).

Re-walkable "undeclared funds" detector for dedicated-wallet trading leagues
(Bankr Trading League shape: $100 USDC/head, declared deposits add to cost
basis, undeclared inbound = disqualification).

Walk: chunked eth_getLogs for ERC20 Transfer (topic0) with topics[2]==wallet,
from league start_block to a pinned head block. Every inbound leg is a row:
tx hash, block, token contract, raw amount, sender. Legs are then diffed
against the contestant's DECLARED deposit list (asset+amount+window).
Anything inbound that no declared row covers is flagged UNDECLARED.

Honest boundaries (say them on every board):
  - ERC20 legs only. Native-ETH inflows are NOT log-visible; tool reports
    the wallet's native balance at head so a nonzero reading is a visible
    flag, but attribution needs a trace/indexer pass (out of scope here).
  - Internal transfers between two contestant wallets still count as
    inbound legs — they get flagged like any undeclared inflow.
  - "Declared" matching is exact-asset + tolerance-window on amount;
    the desk publishes the match table so any stranger can re-derive flags.

Usage:
  python3 league_watch.py config.json [--head N] [--chunk N] [-o outdir]

config.json:
  {"season": "bankr-s1",
   "start_block": 36000000,
   "head_block": null,
   "chunk": 20000,
   "rpcs": ["https://mainnet.base.org", "..."],
   "declared_window_blocks": 0,
   "contestants": [{"name":"arion-demo",
                    "address":"0x...",
                    "declared":[{"token":"0x833589...2913",
                                 "amount_raw":"100000000"}]}]}
"""
import json, os, sys, time, urllib.request

sys.path.insert(0, "/workspace/tools")
from eth_call import keccak256, enc_uint, enc_addr  # noqa: E402

TRANSFER_TOPIC = "0x" + keccak256(b"Transfer(address,address,uint256)").hex()
DEFAULT_RPCS = ["https://mainnet.base.org",          # 2000-blk wildcard getLogs
                "https://base.gateway.tenderly.co"]  # 1000-blk public access
# proven-unsuitable for wildcard scans (2026-09-28): base.drpc.org (400 on
# getLogs free plan), base-rpc.publicnode.com (requires `address` filter),
# base.llamarpc.com (525), base.merkle.io (no getLogs), 0xrpc.io/base (404).
UA = {"Content-Type": "application/json",
      "User-Agent": "ARION-league-desk/1.0 (re-walkable inflow scan)"}
_i = 0
_range_re = __import__("re").compile(
    r"(\d[\d,]*)\s*(?:range|blocks)", __import__("re").I)
_head_re = __import__("re").compile(r"head\s*(?:block)?\s*[:=]?\s*(\d[\d,]*)",
                                    __import__("re").I)


def rpc(rpcs, method, params, tries=8):
    """Rotate RPCs; returns result or raises. On a getLogs range-limit error,
    returns the sentinel {"_range_limit": N} so callers can shrink the chunk."""
    global _i
    body = json.dumps({"jsonrpc": "2.0", "id": 1,
                       "method": method, "params": params}).encode()
    last = None
    for _ in range(tries):
        url = rpcs[_i % len(rpcs)]
        _i += 1
        try:
            req = urllib.request.Request(url, data=body, headers=UA)
            d = json.load(urllib.request.urlopen(req, timeout=30))
            if "result" in d:
                return d["result"]
            err = d.get("error")
            last = err
            msg = " ".join(str((err or {}).get(k, "")) for k in
                           ("message", "data")) if isinstance(err, dict) \
                else str(err)
            m = _range_re.search(msg)
            low = msg.lower()
            if method == "eth_getLogs" and m and any(w in low for w in (
                    "limit", "not supported", "too large", "maximum",
                    "reduce")):
                return {"_range_limit": int(m.group(1).replace(",", ""))}
            h = _head_re.search(msg)
            if method == "eth_getLogs" and h:
                return {"_head": int(h.group(1).replace(",", ""))}
        except Exception as e:  # rotate RPC on transport/parse failure
            last = str(e)
        time.sleep(0.3)
    raise RuntimeError(f"{method} failed on all rpcs: {last}")


def get_logs_inbound(rpcs, wallet, start, head, chunk):
    """All ERC20 Transfer legs landing in wallet, chunked start..head.

    Chunk shrinks adaptively on RPC range-limit errors (base.org=2000,
    drpc free=10000) and re-widens slowly so a season-length walk stays
    cheap on the friendliest endpoint."""
    topic_wallet = "0x" + enc_addr(wallet)
    legs, blk, cur = [], start, chunk
    while blk <= head:
        end = min(blk + cur - 1, head)
        r = rpc(rpcs, "eth_getLogs", [{
            "fromBlock": hex(blk), "toBlock": hex(end),
            "topics": [TRANSFER_TOPIC, None, topic_wallet]}])
        if isinstance(r, dict):
            if "_range_limit" in r:
                cur = max(1, min(r["_range_limit"], cur // 2 or 1))
                continue
            if "_head" in r:          # chain advanced mid-walk; re-pin
                head = min(head, r["_head"])
                end = min(blk + cur - 1, head)
                continue
        for lg in r or []:
            if lg["data"] in ("0x", "0x0") and len(lg["topics"]) >= 4:
                kind, amt = "erc721", str(int(lg["topics"][3], 16))
            else:
                kind, amt = "erc20", str(int(lg["data"], 16))
            legs.append({
                "block": int(lg["blockNumber"], 16),
                "tx": lg["transactionHash"],
                "log_index": int(lg["logIndex"], 16),
                "kind": kind,
                "token": "0x" + lg["address"].lower().replace("0x", ""),
                "from": "0x" + lg["topics"][1][-40:],
                "amount_raw": amt,
            })
        blk = end + 1
        cur = min(cur * 2, chunk)  # widen back toward configured ceiling
    legs.sort(key=lambda r: (r["block"], r["log_index"]))
    return legs


def token_meta(rpcs, token):
    def call(sig):
        sel = "0x" + keccak256(sig.encode())[:4].hex()
        try:
            return rpc(rpcs, "eth_call",
                       [{"to": token, "data": sel}, "latest"], tries=3)
        except Exception:
            return None
    dec, sym = 18, token[:10]
    r = call("decimals()")
    if r and r != "0x":
        dec = int(r, 16)
    r = call("symbol()")
    if r and r != "0x" and len(r) > 130:
        try:
            n = int(r[66:130], 16)
            sym = bytes.fromhex(r[130:130 + n * 2]).decode("utf-8", "replace")
        except Exception:
            pass
    return sym, dec


def declared_match(leg, declared):
    """A leg is declared-covered iff a declared row matches token+amount."""
    for d in declared or []:
        if d.get("token", "").lower() == leg["token"].lower() and \
           str(d.get("amount_raw")) == leg["amount_raw"]:
            return d
    return None


def main():
    cfg_path = sys.argv[1]
    cfg = json.load(open(cfg_path))
    rpcs = cfg.get("rpcs") or DEFAULT_RPCS
    chunk = cfg.get("chunk", 20000)
    head = cfg.get("head_block")
    if "--head" in sys.argv:
        head = int(sys.argv[sys.argv.index("--head") + 1])
    if head is None:
        head = int(rpc(rpcs, "eth_blockNumber", []), 16)
    start = int(cfg["start_block"])
    outdir = cfg.get("outdir") or os.path.dirname(os.path.abspath(cfg_path))
    if "-o" in sys.argv:
        outdir = sys.argv[sys.argv.index("-o") + 1]
    os.makedirs(outdir, exist_ok=True)

    board = {"season": cfg.get("season"), "chain": "eip155:8453",
             "start_block": start, "head_block": head,
             "walk": {"method": "eth_getLogs Transfer topic2=wallet, chunked",
                      "chunk": chunk, "topic0": TRANSFER_TOPIC,
                      "rpcs": rpcs, "pulled_utc": time.strftime(
                          "%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
             "contestants": []}

    for c in cfg.get("contestants", []):
        w = c["address"].lower()
        legs = get_logs_inbound(rpcs, w, start, head, chunk)
        native = int(rpc(rpcs, "eth_getBalance", [w, hex(head)]), 16)
        toks = {}
        for lg in legs:
            toks.setdefault(lg["token"], []).append(lg)
        want_meta = cfg.get("token_meta", True)
        meta = {t: token_meta(rpcs, t) for t in toks} if want_meta else {}
        rows, undeclared = [], []
        for lg in legs:
            cov = declared_match(lg, c.get("declared"))
            if lg["kind"] == "erc721":
                row = {"block": lg["block"], "tx": lg["tx"],
                       "token": lg["token"], "symbol": "ERC-721",
                       "amount": int(lg["amount_raw"]), "from": lg["from"],
                       "declared": bool(cov)}
            else:
                sym, dec = meta.get(lg["token"], (lg["token"][:10], 18))
                amt = int(lg["amount_raw"]) / (10 ** dec)
                row = {"block": lg["block"], "tx": lg["tx"],
                       "token": lg["token"], "symbol": sym,
                       "amount": amt, "from": lg["from"],
                       "declared": bool(cov)}
            rows.append(row)
            if not cov:
                undeclared.append(row)
        board["contestants"].append({
            "name": c.get("name"), "address": c["address"],
            "native_balance_wei": str(native),
            "native_flag": native > 0,
            "inbound_legs": rows, "undeclared_legs": undeclared,
            "verdict": ("CLEAR" if not undeclared and native == 0
                        else "FLAGGED"),
        })
        print(f"[{c.get('name')}] {len(legs)} inbound legs, "
              f"{len(undeclared)} undeclared, native={native}wei -> "
              f"{board['contestants'][-1]['verdict']}")

    out = os.path.join(outdir, "board.json")
    json.dump(board, open(out, "w"), indent=1)
    print(f"board -> {out} (head {head}, {len(legs) if cfg.get('contestants') else 0} legs last wallet)")


if __name__ == "__main__":
    main()
