#!/usr/bin/env python3
"""verify_silence.py -- check the PARALLAX "943 days of silence" finding
against YOUR OWN Bitcoin node. No trust in us required.

WHAT IT CHECKS
  1. Block 0's coinbase carries a printable run of 70 characters.
  2. For every block from 1 to 139,689 the longest printable run is at most 7,
     and every block that reaches 7 carries the pool tag "Eligius".
  3. Block 139,690 is the first block after genesis with a printable run of 20
     or more, and it is a Latin prayer.

DEFINITIONS (mechanical -- no dictionary, no model, no judgement)
  printable byte = 0x20..0x7E
  printable run  = a maximal consecutive stretch of printable bytes in the
                   coinbase scriptSig, with leading/trailing whitespace removed
  longest run    = the length of the longest such stretch in that block

REQUIREMENTS
  A Bitcoin Core node with the blocks for heights 0..139,690 (any non-pruned
  node, or a pruned node that still has that range). Python 3.8+. No pip
  installs -- standard library only.

USAGE
  python3 verify_silence.py                  # sampled: ~2,600 blocks, ~1 minute
  python3 verify_silence.py --full           # every block 0..139,690
  python3 verify_silence.py --rpcuser U --rpcpassword P --rpcport 8332

AUTH is tried in this order, so you never have to put a password on a command
line unless you want to:
  1. --rpcuser / --rpcpassword
  2. environment: BITCOIN_RPC_USER / BITCOIN_RPC_PASSWORD
  3. rpcuser= / rpcpassword= in <datadir>/bitcoin.conf
  4. the auth cookie at <datadir>/.cookie

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

GENESIS_RUN = 70
WINDOW_LAST = 139689
BREAK_HEIGHT = 139690
WINDOW_MAX_RUN = 7
TAG = "Eligius"
BREAK_MIN_RUN = 20


def read_conf(datadir):
    """Pull rpc settings out of bitcoin.conf without importing anything."""
    out = {}
    p = os.path.join(datadir, "bitcoin.conf")
    try:
        for line in open(p):
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            out[k.strip()] = v.strip()
    except OSError:
        pass
    return out


def resolve_auth(a, conf):
    """Return (auth_string, source) without ever printing the secret."""
    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 = url
        self.auth = base64.b64encode(auth.encode()).decode()
        self.n = 0

    def batch(self, calls):
        """calls = [(method, [params]), ...] -> [result, ...]"""
        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=120) 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 longest_printable_run(raw):
    """Longest whitespace-trimmed run of printable ASCII bytes."""
    best, cur = 0, bytearray()
    for b in raw + b"\x00":
        if 0x20 <= b <= 0x7E:
            cur.append(b)
        else:
            s = bytes(cur).strip()
            if len(s) > best:
                best = len(s)
            cur = bytearray()
    return best


def printable_text(raw):
    return "".join(chr(b) if 0x20 <= b <= 0x7E else " " for b in raw).strip()


def coinbases(rpc, heights, chunk=250):
    """Yield (height, raw_scriptsig_bytes) using batched JSON-RPC."""
    for i in range(0, len(heights), chunk):
        part = heights[i:i + chunk]
        hashes = rpc.batch([("getblockhash", [h]) for h in part])
        blocks = rpc.batch([("getblock", [bh, 2]) for bh in hashes])
        for h, blk in zip(part, blocks):
            yield h, bytes.fromhex(blk["tx"][0]["vin"][0]["coinbase"])


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",
                    help="check every block instead of a deterministic sample")
    ap.add_argument("--chunk", type=int, default=250)
    a = ap.parse_args()

    conf = read_conf(a.datadir)
    auth, src = resolve_auth(a, conf)
    if not auth:
        print("no RPC credentials found. Pass --rpcuser/--rpcpassword, set "
              "BITCOIN_RPC_USER/BITCOIN_RPC_PASSWORD, or point --datadir at a "
              "directory holding bitcoin.conf or .cookie (tried %s)" % a.datadir)
        return 2
    host = a.rpchost or conf.get("rpcconnect") or "127.0.0.1"
    if a.rpchost == "127.0.0.1" and conf.get("rpcconnect"):
        host = conf["rpcconnect"]
    port = a.rpcport or int(conf.get("rpcport", 8332))
    print("auth from: %s" % src)
    rpc = RPC("http://%s:%d/" % (host, port), auth)

    try:
        info = rpc.batch([("getblockchaininfo", [])])[0]
    except Exception as ex:
        print("cannot reach your node: %s" % ex)
        return 2
    print("your node: chain=%s blocks=%d pruned=%s"
          % (info["chain"], info["blocks"], info.get("pruned")))
    if info["blocks"] < BREAK_HEIGHT:
        print("FAIL: your node only has %d blocks; this check needs %d"
              % (info["blocks"], BREAK_HEIGHT))
        return 1

    failures = []

    # ---- check 1: genesis
    _, raw = next(iter(coinbases(rpc, [0])))
    g = longest_printable_run(raw)
    ok = (g == GENESIS_RUN)
    print("\n[%s] block 0 longest printable run = %d (expected %d)"
          % ("PASS" if ok else "FAIL", g, GENESIS_RUN))
    print("       %r" % printable_text(raw))
    if not ok:
        failures.append("genesis run %d != %d" % (g, GENESIS_RUN))

    # ---- check 2: the silence window
    if a.full:
        heights = list(range(1, WINDOW_LAST + 1))
        label = "every block"
    else:
        # deterministic sample: every 100th block, plus every block that our
        # finding says reaches the window maximum of 7
        heights = sorted(set(list(range(1, WINDOW_LAST + 1, 100)) +
                             list(range(130635, 139690, 7))))
        label = "deterministic sample"
    print("\nscanning heights 1..%d (%s, %d blocks) ..."
          % (WINDOW_LAST, label, len(heights)))

    worst, worst_h, worst_txt = -1, None, ""
    violations, tagless7 = [], []
    t0 = time.time()
    done = 0
    for h, raw in coinbases(rpc, heights, a.chunk):
        r = longest_printable_run(raw)
        if r > worst:
            worst, worst_h, worst_txt = r, h, printable_text(raw)
        if r > WINDOW_MAX_RUN:
            violations.append((h, r, printable_text(raw)[:70]))
        elif r == WINDOW_MAX_RUN and TAG not in printable_text(raw):
            tagless7.append((h, printable_text(raw)[:70]))
        done += 1
        if done % 5000 == 0:
            print("   %d/%d  (%.0fs)" % (done, len(heights), time.time() - t0))

    ok2 = not violations
    print("\n[%s] no block in 1..%d exceeds a printable run of %d"
          % ("PASS" if ok2 else "FAIL", WINDOW_LAST, WINDOW_MAX_RUN))
    print("       longest found: %d at height %d -- %r" % (worst, worst_h, worst_txt[:60]))
    for h, r, t in violations[:10]:
        print("       VIOLATION h=%d run=%d %r" % (h, r, t))
    if not ok2:
        failures.append("%d blocks exceed run %d" % (len(violations), WINDOW_MAX_RUN))

    ok3 = not tagless7
    print("[%s] every block reaching run %d carries the tag %r"
          % ("PASS" if ok3 else "FAIL", WINDOW_MAX_RUN, TAG))
    for h, t in tagless7[:10]:
        print("       UNTAGGED h=%d %r" % (h, t))
    if not ok3:
        failures.append("%d blocks reach run %d without the tag" % (len(tagless7), WINDOW_MAX_RUN))

    # ---- check 3: the break
    _, raw = next(iter(coinbases(rpc, [BREAK_HEIGHT])))
    r = longest_printable_run(raw)
    ok4 = (r >= BREAK_MIN_RUN)
    print("\n[%s] block %d longest printable run = %d (expected >= %d)"
          % ("PASS" if ok4 else "FAIL", BREAK_HEIGHT, r, BREAK_MIN_RUN))
    print("       %r" % printable_text(raw))
    if not ok4:
        failures.append("break block run %d < %d" % (r, BREAK_MIN_RUN))

    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: this was the sampled run. Use --full to check every block")
        print("      from 1 to %d. The sample is a floor, not a proof of the" % WINDOW_LAST)
        print("      whole window.")
    print("RESULT: ALL CHECKS PASSED against your own node.")
    return 0


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