#!/usr/bin/env python3
"""
beacon-verify — confirm a Touchstone entry's server_ts INTERVAL, trusting no one.

A beacon-bound entry commits a drand round into its entry_hash. That alone proves nothing about
time: chain, round and randomness all came from the party under audit, so reading them out of the
entry and stopping leaves you at k=1 while looking checked — worse than an honest soft clock. The
not-before is real only when you resolve the round against drand ITSELF and confirm the randomness
could not have been known earlier. This tool does that:

  1. recomputes entry_hash WITH the beacon fold and checks it matches the served value (the round is
     bound into the chain, not a free-floating field);
  2. AUTHENTICATES the round's randomness. The strong tier VERIFIES the round's BLS threshold signature
     against the drand quicknet GROUP PUBLIC KEY committed below (custodian-free — a captured relay
     can't forge a signature it doesn't hold the key for), and checks the committed randomness is its
     sha256. If no pairing library is present it drops to CONFIRMED-via-relay (ties the value to the
     served signature + matches the relay's published value, but trusts the relay), and says which tier
     it used. offline → ASSERTED, unconfirmed (never reported as checked). mismatch/bad-sig → FORGED;
  3. folds the Merkle inclusion proof to the checkpoint root (not-after ← the checkpoint's OTS → Bitcoin);
  4. checks the recorder's committed max_sweep bound: >=2 independent explorers must agree on the
     anchoring block, its raw header must double-sha256 to that hash and carry real mainnet proof-of-work,
     then the block time is placed against server_ts + max_sweep_seconds. Bitcoin block time is NOT a
     fine clock (a stamp sits above median-time-past and up to ~2h above real time), so the verdict is
     resolved against monotonic median-time-past and only claims a crisp WITHIN/VIOLATED when the margin
     clears that ~2h resolution — inside it the honest verdict is INDETERMINATE, not a spuriously tight
     number. A late anchor beyond resolution is an on-record fact, not a forgery. Full trustlessness =
     `ots verify` + a node.

Result: server_ts is a checkable INTERVAL [t(round), checkpoint], each leg labelled verified /
confirmed-relay / asserted — never "looks hardened" while resting on the signer.

Stdlib-only for everything except the BLS check: BLS12-381 pairing is deliberately NOT hand-rolled
(a subtly-wrong pairing ACCEPTS forgeries and can't be audited by reading it — the opposite of this
tool's point). The custodian-free tier uses py_ecc when present; `pip install py_ecc` to enable it.

    python3 beacon-verify.py https://touchstone.cv/.well-known/touchstone/checkpoints/<rec>/entry/<seq>
    python3 beacon-verify.py <file.json> --offline      # skip the drand fetch (report asserted)
    python3 beacon-verify.py --selftest
Exit: 0 = entry_hash binds the beacon AND (online) the round confirms · 1 = mismatch / forged / broken.
"""
import sys
import re
import json
import struct
import hashlib
import datetime
import urllib.request

# Independent Bitcoin block sources (for the max_sweep check). The tool never trusts one — it requires
# >=2 to agree on the block hash, then re-fetches the raw header and verifies its proof-of-work itself.
BLOCK_EXPLORERS = [("blockstream", "https://blockstream.info/api"), ("mempool", "https://mempool.space/api")]
# A real mainnet block currently carries ~79 leading-zero bits of work; requiring >=68 rejects a
# fabricated low-difficulty header while staying comfortably below real difficulty.
POW_MAINNET_FLOOR_BITS = 68

# drand quicknet — the beacon Touchstone binds to. Chain params are protocol constants.
DRAND_HASH = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
DRAND_GENESIS = 1692803367
DRAND_PERIOD = 3
# The League of Entropy quicknet GROUP PUBLIC KEY (G2, 96 bytes) — a committed constant, verifiable
# against any drand source. Authenticating a round's signature against THIS key is what makes the
# not-before custodian-free: a captured relay can't forge a signature it doesn't hold the key for.
DRAND_GROUP_PUBKEY = ("83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1"
                      "064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a")
# quicknet is bls-unchained-g1-rfc9380: sig on G1, pubkey on G2, message = sha256(round_be8).
DRAND_DST = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"


def bls_verify_round(rnd, sig_hex):
    """Verify a drand quicknet BLS signature for `rnd` against the committed group key. Returns
    True/False, or None if no pairing library is available.

    BLS12-381 pairing is deliberately NOT vendored here: a subtly-wrong hand-rolled pairing is exactly
    how a verifier ends up ACCEPTING forgeries, and almost no one can audit one by reading it — which
    defeats the point. The Ed25519 elsewhere in this stack is small enough to vendor and read; a pairing
    is not. So the custodian-free check leans on py_ecc (a widely-reviewed pure-Python impl) when present,
    and otherwise says so and drops to relay-trust. `pip install py_ecc` to get the strong tier.
    """
    try:
        import hashlib as _h
        from py_ecc.optimized_bls12_381 import G2, pairing, final_exponentiate
        from py_ecc.bls.hash_to_curve import hash_to_G1
        from py_ecc.bls.point_compression import decompress_G1, decompress_G2
        from py_ecc.bls.typing import G1Compressed, G2Compressed
    except Exception:
        return None
    try:
        pk_b = bytes.fromhex(DRAND_GROUP_PUBKEY)
        pk = decompress_G2(G2Compressed((int.from_bytes(pk_b[:48], "big"), int.from_bytes(pk_b[48:], "big"))))
        sig = decompress_G1(G1Compressed(int.from_bytes(bytes.fromhex(sig_hex), "big")))
        h = hash_to_G1(_h.sha256(rnd.to_bytes(8, "big")).digest(), DRAND_DST, _h.sha256)
        return final_exponentiate(pairing(pk, h, final_exponentiate=False)) == \
            final_exponentiate(pairing(G2, sig, final_exponentiate=False))
    except Exception:
        return False


def sha256_hex(b):
    return hashlib.sha256(b).hexdigest()


def round_time(rnd):
    """t(round) = genesis + (round-1)*period. Pure, offline."""
    return DRAND_GENESIS + (rnd - 1) * DRAND_PERIOD


def entry_hash(e):
    """Recompute entry_hash, folding server_beacon exactly like verify.php / verifier.js / touchstone-
    verify. A present-but-malformed beacon returns a sentinel that can't match → reject (fail closed)."""
    parts = [str(e["seq"]), e["prev_hash"], e["server_ts"], e["payload_hash"],
             e["actor_sub"], e.get("counterparty_sub") or "", e["actor_sig"]]
    b = e.get("server_beacon")
    if b is not None:
        if not isinstance(b, dict) or not all(k in b for k in ("chain", "round", "randomness")):
            return "malformed-server_beacon"
        parts.append("beacon:%s:%s:%s" % (b["chain"], b["round"], b["randomness"]))
    return sha256_hex("\n".join(parts).encode("utf-8"))


# --- RFC 6962 Merkle (mirrors MerkleService) — the not-after leg ---
def _leaf(h):
    return sha256_hex(b"\x00" + bytes.fromhex(h))


def _node(l, r):
    return sha256_hex(b"\x01" + bytes.fromhex(l) + bytes.fromhex(r))


def fold_proof(eh, proof):
    acc = _leaf(eh)
    for s in proof:
        acc = _node(s["hash"], acc) if s.get("side") == "left" else _node(acc, s["hash"])
    return acc


def get_json(url, timeout=10):
    req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "beacon-verify/1"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def _http_text(url, timeout=12):
    req = urllib.request.Request(url, headers={"User-Agent": "beacon-verify/1"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read().decode("utf-8").strip()


def verify_bitcoin_block(height, expected_hash, offline):
    """Independently confirm the block at `height` and return (state, times, detail).

    Never trusts the checkpoint's served block info: >=2 independent explorers must agree on the block
    hash at that height, the raw 80-byte header must double-sha256 to that hash, and it must carry real
    mainnet proof-of-work (hash < the header's own target AND below a mainnet-difficulty floor, so a
    fabricated low-difficulty header is rejected). Full trustlessness (that this block is the anchoring
    one, in the main chain) is `ots verify` with your own node — this is the honest, node-free tier:
    PoW-verified + multi-source-corroborated.

    `times` is {"ts": header_stamp, "mtp": median_time_past_or_None}. The header stamp is the PoW-verified
    block time; median-time-past (BIP113 — the median of the last 11 blocks) is the monotonic,
    consensus-meaningful time that a single miner cannot move. MTP is not in the 80-byte header, so it is
    read from the block JSON and corroborated across explorers, with the JSON's own `timestamp`
    cross-checked against the PoW-verified stamp.
      'verified'  block confirmed + PoW-checked; times returned
      'forged'    header/PoW/hash mismatch — do not trust the anchor time
      'asserted'  offline, or fewer than 2 explorers reachable/agreeing
    """
    if offline:
        return "asserted", None, "--offline: block not fetched"
    hashes = {}
    for _name, base in BLOCK_EXPLORERS:
        try:
            h = _http_text("%s/block-height/%d" % (base, height))
            if re.match(r"^[0-9a-f]{64}$", h):
                hashes.setdefault(h, 0)
                hashes[h] += 1
        except Exception:
            continue
    agreed = [h for h, n in hashes.items() if n >= 2]
    if not agreed:
        return "asserted", None, "fewer than 2 explorers agreed on the block at height %d" % height
    block_hash = agreed[0]
    if expected_hash and block_hash != expected_hash:
        return "forged", None, "the block at height %d is not the anchor's claimed hash" % height
    try:
        raw = bytes.fromhex(_http_text("%s/block/%s/header" % (BLOCK_EXPLORERS[0][1], block_hash)))
    except Exception as ex:
        return "asserted", None, "could not fetch the block header (%s)" % ex
    if len(raw) != 80:
        return "forged", None, "block header is not 80 bytes"
    computed = hashlib.sha256(hashlib.sha256(raw).digest()).digest()[::-1].hex()
    if computed != block_hash:
        return "forged", None, "header does not double-sha256 to the block hash"
    ts = struct.unpack("<I", raw[68:72])[0]
    bits = struct.unpack("<I", raw[72:76])[0]
    exp, mant = bits >> 24, bits & 0xFFFFFF
    target = mant * (1 << (8 * (exp - 3)))
    hi = int.from_bytes(bytes.fromhex(computed), "big")
    if not (hi < target and hi < (1 << (256 - POW_MAINNET_FLOOR_BITS))):
        return "forged", None, "header does not carry real mainnet proof-of-work"
    # Median-time-past: monotonic, single-miner-proof block time (BIP113). Not in the header — read from
    # the block JSON, corroborated across explorers, with each JSON's `timestamp` cross-checked to the
    # PoW-verified stamp so a lying explorer can't slip a fake MTP past a real header.
    mtps = {}
    for _name, base in BLOCK_EXPLORERS:
        try:
            bj = get_json("%s/block/%s" % (base, block_hash))
            if int(bj.get("timestamp", -1)) == ts and isinstance(bj.get("mediantime"), int):
                mtps[bj["mediantime"]] = mtps.get(bj["mediantime"], 0) + 1
        except Exception:
            continue
    agreed_mtp = [m for m, n in mtps.items() if n >= 2]
    mtp = agreed_mtp[0] if agreed_mtp else None
    return "verified", {"ts": ts, "mtp": mtp}, (
        "block %d confirmed across 2 explorers, header PoW-verified (%d leading zero bits)%s"
        % (height, 256 - hi.bit_length(),
           "; median-time-past corroborated" if mtp is not None else "; MTP unavailable, using header stamp"))


# Bitcoin block time is not a fine clock: consensus only requires a stamp be above the median-time-past
# of the last 11 blocks and below network-adjusted time + 2h. So a not-after resolved from block time
# carries ~2h of slack — the check goes hard, its resolution stays coarse. We publish the bound with
# that granularity attached rather than a spuriously tight number.
BLOCK_TIME_RESOLUTION_SECONDS = 7200


def max_sweep_verdict(deadline, ts, mtp, res=BLOCK_TIME_RESOLUTION_SECONDS):
    """Place the anchoring block relative to the committed deadline, HONEST about block-time resolution.

    Returns (symbol, verdict_text). `ts` is the PoW-verified header stamp; `mtp` the monotonic
    median-time-past (or None). Because a block stamp is coarse to ~`res`, a crisp WITHIN/VIOLATED is
    claimed only when the margin clears that resolution; inside it the honest verdict is INDETERMINATE.
    MTP, being monotonic and un-gameable forward, gives one hard VIOLATED even inside the band: if the
    median-time-past is already past the deadline, the anchor was demonstrably late.
    """
    if mtp is not None and mtp > deadline:
        return "✗", "VIOLATED — median-time-past is already past the deadline (monotonic, on record)"
    if deadline - ts > res:
        return "✓", "WITHIN the committed bound (margin clears block-time resolution)"
    if ts - deadline > res:
        return "✗", "VIOLATED — block stamp is past the deadline beyond resolution (on record)"
    return "·", "INDETERMINATE at block-time resolution (~%dh) — block time cannot resolve this margin" % (res // 3600)


def _iso(ts):
    return datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).isoformat()


def confirm_drand(rnd, randomness, offline):
    """Authenticate the committed randomness for round `rnd`. Returns (state, detail, tier), where the
    tier NAMES which implementation vouched for the value (see TIER_RESTS) — not a bare boolean:
      'verified'       tier bls/py_ecc  — the round's BLS signature verifies against the committed group
                         key AND the randomness is its sha256. Custodian-free, but library-verified: the
                         pairing check rests on py_ecc, a distinct security object from a dep-free reimpl.
      'confirmed_relay'tier relay       — no pairing lib present, so the value only ties to the served
                         signature and matches the relay's published value. Trusts the relay.
      'forged'         tier forged      — the signature doesn't authenticate, or randomness isn't its sha256.
      'asserted'       tier unconfirmed — offline / drand unreachable; not checked.
    """
    if offline:
        return "asserted", "--offline: not fetched", "unconfirmed"
    try:
        got = get_json("https://api.drand.sh/%s/public/%s" % (DRAND_HASH, rnd))
    except Exception as ex:
        return "asserted", "drand unreachable (%s)" % ex, "unconfirmed"

    sig = got.get("signature")
    # Dep-free tie: the committed randomness must be sha256 of a round-R signature. (Necessary, not
    # sufficient — an attacker can pick any S and commit sha256(S); the BLS check below is what proves
    # S is drand's real signature.)
    if not isinstance(sig, str) or sha256_hex(bytes.fromhex(sig)) != randomness:
        return "forged", "committed randomness is not the sha256 of drand's round-%s signature" % rnd, "forged"

    bls = bls_verify_round(rnd, sig)
    if bls is True:
        return "verified", "BLS signature verifies against the drand group key via py_ecc (relay not trusted)", "bls/py_ecc"
    if bls is False:
        return "forged", "signature does NOT verify against the drand group key — fabricated round", "forged"
    # No pairing library → can't authenticate the signature; fall back to trusting the relay's value.
    if got.get("randomness") != randomness:
        return "forged", "committed randomness != the relay's published value for round %s" % rnd, "forged"
    return "confirmed_relay", ("randomness ties to the served signature and matches the relay, but is "
                               "NOT signature-verified — install py_ecc to authenticate against the group key"), "relay"


# Typed trust tiers: each leg says WHICH implementation vouched for it, not just that something did. A
# relier gates on the tier its OWN trust model accepts — someone who trusts a dep-free reimplementation
# but not the py_ecc pairing reads `bls/py_ecc` as "not mine to trust" and stops at the relay tier.
# (exori, Soft Clock thread: dep-free-verified and library-verified are different security objects; a
# boolean `verified` hides which one signed off — the same fetch-vs-verify seam, one level down.)
TIER_RESTS = {
    "bls/py_ecc": "the py_ecc pairing LIBRARY (widely reviewed, but not dep-free) — trust only a dep-free "
                  "reimpl and you have the `relay` tier here, not this one",
    "relay": "api.drand.sh serving the round honestly — the value is NOT signature-authenticated",
    "unconfirmed": "nothing checked (offline / drand unreachable)",
    "merkle->checkpoint": "your own RFC 6962 fold (dep-free) — binds only to the checkpoint; Bitcoin still "
                          "needs your own `ots verify` (the `ots/node` tier this tool does not claim for you)",
    "pow/2-explorer": "your own re-derivation of the header proof-of-work + two explorers agreeing on the "
                      "block hash (node-free) — your own node / `ots verify` removes even the 2-explorer trust",
}


def _tier_summary(tiers):
    """Format the collected (leg, tier) pairs into a block that names what signed off on each leg."""
    if not tiers:
        return []
    out = ["", "  trust tiers — each names WHAT signed off; gate on the tier your model accepts:"]
    width = max(len(leg) for leg, _ in tiers)
    for leg, tier in tiers:
        out.append("    %-*s  %-18s rests on %s" % (width, leg, tier, TIER_RESTS.get(tier, "?")))
    return out


def verify(doc, offline):
    """doc is an inclusion-proof object (has .entry) or a bare entry. Returns (exit_code, lines)."""
    lines = []
    tiers = []   # (leg, tier) — the typed provenance of each leg, summarized at the end
    entry = doc.get("entry") if isinstance(doc, dict) and "entry" in doc else doc
    if not isinstance(entry, dict) or "entry_hash" not in entry:
        return 2, ["no entry found (pass an inclusion-proof URL/file or a bare entry)."]

    # 1. entry_hash binds the beacon into the chain
    recomputed = entry_hash(entry)
    eh_ok = recomputed == entry["entry_hash"]
    lines.append("  %s entry_hash recompute %s served (beacon folded into the chain)"
                 % ("✓" if eh_ok else "✗", "==" if eh_ok else "!="))
    if not eh_ok:
        lines.append("    the entry does not hash to its committed value — do not trust it.")
        return 1, lines

    b = entry.get("server_beacon")
    if b is None:
        lines.append("  · no beacon committed — server_ts is the recorder's word alone (k=1). No not-before.")
        return 0, lines

    # 2. not-before: resolve the round against drand ITSELF
    rnd = b["round"]
    t = round_time(rnd)
    state, detail, nb_tier = confirm_drand(rnd, b["randomness"], offline)
    tiers.append(("not_before", nb_tier))
    ts = entry.get("server_ts")
    lines.append("  committed beacon : %s round %s → t(round) %s" % (b["chain"], rnd, _iso(t)))
    if state == "verified":
        lines.append("  ✓ not-before VERIFIED (custodian-free) — %s. server_ts (%s) could not predate the" % (detail, ts))
        lines.append("    round; the value authenticates against the committed group key, so no relay is trusted.")
    elif state == "confirmed_relay":
        lines.append("  ✓ not-before CONFIRMED via relay — %s. server_ts (%s) sits after the round, but the" % (detail, ts))
        lines.append("    check trusts api.drand.sh to serve honestly; py_ecc upgrades this to custodian-free.")
    elif state == "forged":
        lines.append("  ✗ not-before FORGED — %s. The round was never really observed; reject the clock." % detail)
        return 1, lines
    else:
        lines.append("  · not-before ASSERTED, UNCONFIRMED — %s. Round-time is consistent, but until you" % detail)
        lines.append("    fetch the round the value came from the party under audit. Re-run online to confirm.")

    # 3. not-after: fold the inclusion proof to the checkpoint root (→ its OTS → Bitcoin)
    proof = doc.get("inclusion_proof") if isinstance(doc, dict) else None
    cp = doc.get("checkpoint") if isinstance(doc, dict) else None
    if isinstance(proof, list) and isinstance(cp, dict) and cp.get("merkle_root"):
        root_ok = fold_proof(entry["entry_hash"], proof) == cp["merkle_root"]
        lines.append("  %s not-after ← inclusion proof folds to checkpoint #%s root %s… (%d hops)"
                     % ("✓" if root_ok else "✗", cp.get("id"), cp["merkle_root"][:12], len(proof)))
        if root_ok:
            tiers.append(("not_after", "merkle->checkpoint"))
        if cp.get("ots"):
            lines.append("    walk it to Bitcoin: curl -O <feed>%s && ots verify %s" % (cp["ots"], cp["ots"].rsplit("/", 1)[-1]))
        if not root_ok:
            return 1, lines
    else:
        lines.append("  · not-after: no inclusion proof here (pass the /entry/<seq> endpoint, or anchor the")
        lines.append("    digest yourself — a tight upper leg needs no cooperation from the signer).")

    # 4. max_sweep: the recorder's committed anchor deadline, checked against the Bitcoin block TIME.
    # "committed before the sweep it describes, or it is only a description of what happened" — the bound
    # is in the hash-committed genesis policy, so a late anchor is a provable, on-record violation.
    max_sweep = doc.get("max_sweep_seconds") if isinstance(doc, dict) else None
    btc = cp.get("bitcoin") if isinstance(cp, dict) else None
    if max_sweep and isinstance(btc, dict) and btc.get("height"):
        try:
            server_epoch = int(datetime.datetime.fromisoformat(str(entry["server_ts"]).replace("Z", "+00:00")).timestamp())
        except Exception:
            server_epoch = None
        bstate, btimes, bdetail = verify_bitcoin_block(int(btc["height"]), btc.get("hash"), offline)
        if bstate == "verified" and btimes is not None and server_epoch is not None:
            deadline = server_epoch + int(max_sweep)
            ts, mtp = btimes["ts"], btimes.get("mtp")
            ref = mtp if mtp is not None else ts
            ref_name = "median-time-past" if mtp is not None else "header stamp"
            res = BLOCK_TIME_RESOLUTION_SECONDS
            tiers.append(("max_sweep", "pow/2-explorer"))
            sym, verdict = max_sweep_verdict(deadline, ts, mtp)
            lines.append("  %s max_sweep: anchoring block %s %s (header stamp %s); deadline server_ts+%ds = %s → %s"
                         % (sym, ref_name, _iso(ref), _iso(ts), max_sweep, _iso(deadline), verdict))
            lines.append("    block time is coarse: a stamp sits above median-time-past and up to ~%dh above real "
                         "time, so this verdict resolves no finer than ~%ds (margin here: %ds)."
                         % (res // 3600, res, deadline - ts))
            if sym == "·":
                lines.append("    the margin is inside that resolution — the block clock can't crisply place the anchor "
                             "against the deadline. Use `ots verify` with your own node for a finer bound.")
            if int(max_sweep) < res:
                lines.append("    note: the committed bound (%ds) is itself tighter than block-time resolution (~%ds); a "
                             "bound this tight is only checkable against calendar/node timestamps, not the block clock alone."
                             % (max_sweep, res))
            lines.append("    Full trustlessness: `ots verify` the checkpoint with your own node. %s" % bdetail)
            if sym == "✗":
                lines.append("    the entry is intact; the recorder broke its own committed sweep bound — a fact, not a forgery.")
        elif bstate == "forged":
            lines.append("  ✗ max_sweep: the anchoring block could not be confirmed — %s. Do not rely on the anchor time." % bdetail)
            return 1, lines
        else:
            lines.append("  · max_sweep: declared %ds bound; %s (re-run online to check the anchoring block's time)." % (max_sweep, bdetail))
    elif max_sweep:
        lines.append("  · max_sweep: recorder committed a %ds bound, but the checkpoint is not yet Bitcoin-confirmed (check back)." % max_sweep)

    lines.extend(_tier_summary(tiers))

    lines.append("")
    if state == "verified":
        lines.append("RESULT: server_ts is a VERIFIED interval [%s (drand, custodian-free via bls/py_ecc), checkpoint]." % _iso(t))
    elif state == "confirmed_relay":
        lines.append("RESULT: server_ts is a CONFIRMED interval [%s (drand, tier relay), checkpoint] — "
                     "install py_ecc for the bls/py_ecc tier." % _iso(t))
    else:
        lines.append("RESULT: entry_hash binds the beacon, but not-before is ASSERTED (tier unconfirmed) until you fetch drand.")
    return 0, lines


def selftest():
    ok = []
    # round-time formula (round 30308532 → 1783728960, verified against api.drand.sh)
    ok.append(("round_time", round_time(30308532) == 1783728960))
    # entry_hash fold: present beacon changes the hash; malformed → sentinel
    e = {"seq": 1, "prev_hash": "aa", "server_ts": "t", "payload_hash": "bb",
         "actor_sub": "s", "counterparty_sub": None, "actor_sig": "x"}
    base = entry_hash(e)
    bound = entry_hash({**e, "server_beacon": {"chain": "drand:quicknet", "round": 5, "randomness": "ab" * 32}})
    ok.append(("fold changes hash", base != bound))
    ok.append(("malformed → sentinel", entry_hash({**e, "server_beacon": "x"}) == "malformed-server_beacon"))
    # offline path reports asserted, not confirmed — and its typed tier is `unconfirmed`, never a
    # bare "verified" (the tier NAMES what signed off; nothing did here).
    st, _, st_tier = confirm_drand(5, "ab" * 32, offline=True)
    ok.append(("offline → asserted", st == "asserted"))
    ok.append(("offline tier → unconfirmed", st_tier == "unconfirmed"))
    # the verified tier NAMES its implementation: bls/py_ecc (library-verified), never a bare boolean.
    ok.append(("verified tier names py_ecc", TIER_RESTS["bls/py_ecc"].startswith("the py_ecc pairing LIBRARY")))
    # BLS: real round 30314353 signature verifies against the committed group key; a wrong round does
    # not. Skipped (not failed) when no pairing library is present — the tool still runs relay-trusted.
    _real_sig = ("add8853b576dd2fbc9335d2ec9c4d7b39c5dbb009c01cd96d2d0a1ab2395f1314ac87db1c44efc1e454c6e908e339460")
    _bls = bls_verify_round(30314353, _real_sig)
    if _bls is None:
        print("  %-24s %s" % ("bls verify", "skip (no py_ecc — relay-trusted tier only)"))
    else:
        ok.append(("bls real round verifies", _bls is True))
        ok.append(("bls wrong round rejected", bls_verify_round(30314354, _real_sig) is False))
    # Bitcoin PoW math (offline): block 957526's real 80-byte header double-sha256s to its hash, carries
    # real mainnet work, and timestamps at 1783749808 — the arithmetic behind the max_sweep block-time
    # check, gated without a network fetch.
    _hdr = bytes.fromhex(
        "0080cb25af98431e9a3625e2249126e92acd48489005923bff9f0000000000000000000"
        "06afdf1677f8c572ea8aaa56c847880012239593e77d6a68b7ef8163f148f82ecb0dc516a421a0217ee0bbac4")
    _bh = hashlib.sha256(hashlib.sha256(_hdr).digest()).digest()[::-1].hex()
    ok.append(("btc header hashes to block", _bh == "00000000000000000001e8cc9456662e8c0f15dc40f1ebbe3101f9a62e1d999a"))
    ok.append(("btc header timestamp", struct.unpack("<I", _hdr[68:72])[0] == 1783749808))
    _bits = struct.unpack("<I", _hdr[72:76])[0]
    _tgt = (_bits & 0xFFFFFF) * (1 << (8 * ((_bits >> 24) - 3)))
    _hi = int.from_bytes(bytes.fromhex(_bh), "big")
    ok.append(("btc PoW valid + mainnet-real", _hi < _tgt and _hi < (1 << (256 - POW_MAINNET_FLOOR_BITS))))
    # max_sweep verdict resolution band: block time is coarse to ~2h, so a margin inside that window is
    # INDETERMINATE (not a spuriously tight WITHIN). cp61's real 218s margin is the motivating case.
    _d = 1000000
    ok.append(("max_sweep clear WITHIN", max_sweep_verdict(_d, _d - 8000, _d - 9000)[0] == "✓"))
    ok.append(("max_sweep 218s → INDETERMINATE", max_sweep_verdict(_d, _d - 218, _d - 3818)[0] == "·"))
    ok.append(("max_sweep clear VIOLATED (stamp)", max_sweep_verdict(_d, _d + 8000, _d - 100)[0] == "✗"))
    ok.append(("max_sweep MTP-past-deadline VIOLATED", max_sweep_verdict(_d, _d + 200, _d + 50)[0] == "✗"))
    ok.append(("max_sweep no-MTP falls back to stamp", max_sweep_verdict(_d, _d - 100, None)[0] == "·"))
    for name, good in ok:
        print("  %-24s %s" % (name, "ok" if good else "FAIL"))
    bad = [n for n, g in ok if not g]
    print("\n" + ("SELFTEST FAILED: " + ", ".join(bad) if bad else "SELFTEST OK — fold, sentinel, round-time, offline-degradation."))
    return 1 if bad else 0


def main(argv):
    if "--selftest" in argv[1:]:
        return selftest()
    args = [a for a in argv[1:] if not a.startswith("--")]
    offline = "--offline" in argv[1:]
    if not args:
        print(__doc__)
        return 2
    src = args[0]
    doc = get_json(src) if src.startswith(("http://", "https://")) else json.load(open(src, encoding="utf-8"))
    print("beacon-verify — is server_ts a checkable interval, or the signer's word?\n")
    rc, lines = verify(doc, offline)
    for ln in lines:
        print(ln)
    return rc


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