#!/usr/bin/env python3
"""verify_rolling.py -- check the PARALLAX "ballot became a nonce" finding
against YOUR OWN Bitcoin node. No trust in us required.

WHAT IT CHECKS
  1. Block 398,364 (2016-02-14) is the Bitcoin Classic ballot pattern 0x30000000
     -- the first block in history whose BIP320 grind field is non-zero.
  2. Block 513,424 (2018-03-14) carries 0x20fff000 -- the first block whose
     grind field departs from the single-bit ballot pattern.
  3. The adoption curve: the share of blocks with rolled version bits per
     calendar year, 2015 (0.00%) through 2025 (96.64%).
     Sampled by default; --full reproduces our exact annual counts.

DEFINITIONS (mechanical)
  TOP    = version & 0xE0000000     rolled requires TOP == 0x20000000
  GRIND  = version & 0x1FFFE000     bits 13..28 (BIP320 general-use field)
  rolled = TOP == 0x20000000 AND GRIND != 0
  year   = UTC year of the block header timestamp
  2026 counts stop at height 960,000 (the finding's frozen coverage).

REQUIREMENTS
  Bitcoin Core with blocks up to height 960,000. Python 3.8+, stdlib only.

USAGE
  python3 verify_rolling.py                  # landmarks + sampled shares, ~30s
  python3 verify_rolling.py --full           # exact annual counts, ~4 minutes
  python3 verify_rolling.py --rpcuser U --rpcpassword P

AUTH order: --rpcuser/--rpcpassword, then BITCOIN_RPC_USER/BITCOIN_RPC_PASSWORD
env vars, then rpcuser/rpcpassword in <datadir>/bitcoin.conf, then the .cookie.

Exit code 0 means every check passed against your node.
"""
import argparse, base64, json, os, sys, time, urllib.request

FROZEN = 960000
SCAN_START = 330000          # mid-Dec 2014; earlier years are asserted rolled-free
TOPMASK, TOP001, GRIND = 0xE0000000, 0x20000000, 0x1FFFE000

LANDMARKS = [
    (398364, 0x30000000, "first non-zero grind field ever -- the Classic ballot"),
    (513424, 0x20fff000, "first multi-bit rolled block"),
]

# our exact annual counts at the freeze (heights <= 960,000)
ANNUAL = {2015: (54321, 0), 2016: (54851, 1808), 2017: (55928, 368),
          2018: (54498, 2883), 2019: (54232, 23635), 2020: (53222, 35985),
          2021: (52686, 38549), 2022: (53188, 44847), 2023: (53999, 49815),
          2024: (53473, 50977), 2025: (53082, 51300), 2026: (29660, 28503)}


def read_conf(datadir):
    out = {}
    try:
        for line in open(os.path.join(datadir, "bitcoin.conf")):
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                out[k.strip()] = v.strip()
    except OSError:
        pass
    return out


def resolve_auth(a, conf):
    if a.rpcuser and a.rpcpassword:
        return "%s:%s" % (a.rpcuser, a.rpcpassword), "command line"
    u, p = os.environ.get("BITCOIN_RPC_USER"), os.environ.get("BITCOIN_RPC_PASSWORD")
    if u and p:
        return "%s:%s" % (u, p), "environment"
    if conf.get("rpcuser") and conf.get("rpcpassword"):
        return "%s:%s" % (conf["rpcuser"], conf["rpcpassword"]), "bitcoin.conf"
    for c in (os.path.join(a.datadir, ".cookie"),
              os.path.join(a.datadir, "bitcoin", ".cookie")):
        if os.path.exists(c):
            try:
                return open(c).read().strip(), "cookie file"
            except OSError:
                pass
    return None, None


class RPC:
    def __init__(self, url, auth):
        self.url, self.n = url, 0
        self.auth = base64.b64encode(auth.encode()).decode()

    def batch(self, calls):
        body = json.dumps([{"jsonrpc": "2.0", "id": i, "method": m, "params": p}
                           for i, (m, p) in enumerate(calls)]).encode()
        req = urllib.request.Request(self.url, data=body,
                                     headers={"Authorization": "Basic " + self.auth,
                                              "Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=180) as r:
            out = json.load(r)
        self.n += len(calls)
        out.sort(key=lambda d: d["id"])
        for d in out:
            if d.get("error"):
                raise RuntimeError("RPC error: %s" % d["error"])
        return [d["result"] for d in out]


def rolled(v):
    v &= 0xFFFFFFFF
    return 1 if ((v & TOPMASK) == TOP001 and (v & GRIND) != 0) else 0


def headers(rpc, heights, chunk=500):
    for i in range(0, len(heights), chunk):
        part = heights[i:i + chunk]
        hashes = rpc.batch([("getblockhash", [h]) for h in part])
        hdrs = rpc.batch([("getblockheader", [bh]) for bh in hashes])
        for h, hd in zip(part, hdrs):
            yield h, hd["version"] & 0xFFFFFFFF, hd["time"]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--rpcport", type=int, default=8332)
    ap.add_argument("--rpchost", default="127.0.0.1")
    ap.add_argument("--rpcuser")
    ap.add_argument("--rpcpassword")
    ap.add_argument("--datadir", default=os.path.expanduser("~/.bitcoin"))
    ap.add_argument("--full", action="store_true")
    a = ap.parse_args()

    conf = read_conf(a.datadir)
    auth, src = resolve_auth(a, conf)
    if not auth:
        print("no RPC credentials found (tried CLI, env, bitcoin.conf, cookie under %s)"
              % a.datadir)
        return 2
    host = conf.get("rpcconnect") if (a.rpchost == "127.0.0.1" and conf.get("rpcconnect")) \
        else a.rpchost
    rpc = RPC("http://%s:%d/" % (host, a.rpcport or int(conf.get("rpcport", 8332))), auth)
    print("auth from: %s" % src)

    info = rpc.batch([("getblockchaininfo", [])])[0]
    print("your node: chain=%s blocks=%d pruned=%s"
          % (info["chain"], info["blocks"], info.get("pruned")))
    if info["blocks"] < FROZEN:
        print("FAIL: this check needs blocks up to %d; your node has %d"
              % (FROZEN, info["blocks"]))
        return 1

    failures = []
    t0 = time.time()

    # ---- landmarks
    for h, expect_v, why in LANDMARKS:
        _, v, _ = next(iter(headers(rpc, [h])))
        ok = (v == expect_v)
        print("\n[%s] block %d version = 0x%08x (expected 0x%08x)"
              % ("PASS" if ok else "FAIL", h, v, expect_v))
        print("       %s; rolled-flag=%d" % (why, rolled(v)))
        if not ok:
            failures.append("block %d version 0x%08x != 0x%08x" % (h, v, expect_v))

    # ---- the curve
    if a.full:
        heights = list(range(SCAN_START, FROZEN + 1))
        label = "every block %d..%d" % (SCAN_START, FROZEN)
    else:
        heights = list(range(SCAN_START, FROZEN + 1, 100))
        label = "deterministic sample, every 100th block"
    print("\nscanning %s (%d blocks) ..." % (label, len(heights)))

    import datetime
    counts = {}
    done = 0
    for h, v, ts in headers(rpc, heights):
        yr = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).year
        b, r = counts.get(yr, (0, 0))
        counts[yr] = (b + 1, r + rolled(v))
        done += 1
        if done % 100000 == 0:
            print("   %d/%d  (%.0fs)" % (done, len(heights), time.time() - t0))

    print("\n%6s %22s %22s  %s" % ("year", "your node (n, rolled)", "share", "verdict"))
    for yr in sorted(y for y in counts if 2015 <= y <= 2026):
        b, r = counts[yr]
        share = 100.0 * r / b
        ours_b, ours_r = ANNUAL[yr]
        ours = 100.0 * ours_r / ours_b
        if a.full:
            ok = (b == ours_b and r == ours_r)
            verdict = "exact match" if ok else "MISMATCH (ours: %d/%d)" % (ours_r, ours_b)
        else:
            # tolerance is DERIVED, not hand-picked: 3 binomial sigmas at your
            # sample size around our published share, floored at 1 point
            import math
            p = ours / 100.0
            tol = max(1.0, 300.0 * math.sqrt(p * (1 - p) / b))
            ok = abs(share - ours) <= tol
            verdict = ("within 3-sigma (%.1f pts) of our %.2f%%" % (tol, ours)) if ok \
                else "outside 3-sigma (%.1f pts) of our %.2f%%" % (tol, ours)
        print("%6d %14d %7d %21.2f%%  [%s] %s"
              % (yr, b, r, share, "PASS" if ok else "FAIL", verdict))
        if not ok:
            failures.append("year %d" % yr)

    print("\n%s" % ("-" * 72))
    print("blocks read from your node: %d   RPC calls: %d   %.0fs"
          % (len(heights) + 2, rpc.n, time.time() - t0))
    if failures:
        print("RESULT: FAILED -- %s" % "; ".join(failures))
        return 1
    if not a.full:
        print("NOTE: sampled run. Use --full to reproduce our exact annual counts.")
    print("RESULT: ALL CHECKS PASSED against your own node.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
