#!/usr/bin/env python3 """A second reader of JCP's published hold-lifecycle log (SPEC ยง8.1, C32). `v1/lifecycle.json` is one hash-chained log holding the life of a money hold: a hold captured in part and then reclaimed past its deadline, a hold captured and settled, a spend refunded by its issuer, and a refund refused to its subject. It publishes the ledger a reader must restore from it. This reads that log by the specification's rules and refuses unless it restores the same ledger. It shares no code with the reference implementation. The envelope, CBOR, Ed25519 and BLAKE3 come from `verify_replay.py`, which is itself independent of it; the rules below are written from the specification: - every entry restores its own debits, in joules and in money; - a hold no entry settles restores what it STILL reserves: its `held_uj` and `held_minor` less what the entries naming it in `captures` debited, since those entries restore their own debits and the whole reservation beside them would count the captured part twice; - a hold an allowed entry names in `reclaims` restores no money, and still its joules; - an allowed entry stating `refunded_minor` restores a refund, beside a gross debit that never decreases. A refused one refunds nothing. `--selftest` then re-reads the log with each rule broken in turn and requires every one of those readers to be refused, so a pass means the log distinguishes the rules. """ import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from verify_replay import Refused, blake3, open_envelope, read_debits, read_money # noqa: E402 def is_hold(core): return "held_uj" in core and core["verdict"] == "allow" def grants(core): return core.get("chain_ids", [core["grant_id"]]) def read_log(doc, steward, key): """The verified cores and their ids, or Refused where the chain breaks.""" prev = "0" * 64 cores, ids = [], [] for i, entry in enumerate(doc["entries"]): envelope = bytes.fromhex(entry["envelope_hex"]) core = open_envelope(envelope, steward, key) if core != entry["wire"]: raise Refused(f"entry {i}: the signed core is not the published wire form") if core["seq"] != i or core["prev"] != prev: raise Refused(f"entry {i}: the chain is broken here") prev = blake3(envelope).hex() cores.append(core) ids.append(prev) return cores, ids def restore(cores, ids, captured_less=True, reclaim_releases=True, refused_refunds=False, refund_lowers_gross=False): """The ledger the log restores: (joules, money, refunded), each {grant: n}. The keyword flags break one rule each, for the selftest.""" settled = {c["settles"] for c in cores if "settles" in c} reclaimed = {c["reclaims"] for c in cores if "reclaims" in c and c["verdict"] == "allow"} drawn_uj, drawn_minor = {}, {} for c in cores: if "captures" in c: for g, n in read_debits(c): drawn_uj[(c["captures"], g)] = drawn_uj.get((c["captures"], g), 0) + n for g, n in read_money(c): drawn_minor[(c["captures"], g)] = drawn_minor.get((c["captures"], g), 0) + n uj, minor, refunded = {}, {}, {} def add(ledger, grant, n): if n: ledger[grant] = ledger.get(grant, 0) + n for core, own in zip(cores, ids): for g, n in read_debits(core): add(uj, g, n) for g, n in read_money(core): add(minor, g, n) if "refunded_minor" in core and (core["verdict"] == "allow" or refused_refunds): if refund_lowers_gross: minor[core["grant_id"]] = minor.get(core["grant_id"], 0) - core["refunded_minor"] else: add(refunded, core["grant_id"], core["refunded_minor"]) if is_hold(core) and own not in settled: for g in grants(core): less = drawn_uj.get((own, g), 0) if captured_less else 0 add(uj, g, max(0, core["held_uj"] - less)) if "held_minor" in core and not (reclaim_releases and own in reclaimed): less = drawn_minor.get((own, g), 0) if captured_less else 0 add(minor, g, max(0, core["held_minor"] - less)) return uj, minor, refunded def check(doc, steward, key, **rules): cores, ids = read_log(doc, steward, key) got = restore(cores, ids, **rules) for name, ledger in zip(("spent_after", "money_after", "refunded_after"), got): if ledger != doc[name]: raise Refused(f"the log restores {name} {ledger}, not the published {doc[name]}") return len(cores) def check_coverage(doc): """Refused unless the log still holds each form the rules distinguish.""" wires = [e["wire"] for e in doc["entries"]] ids = [blake3(bytes.fromhex(e["envelope_hex"])).hex() for e in doc["entries"]] settled = {w["settles"] for w in wires if "settles" in w} forms = { "a hold stating money": any(is_hold(w) and "held_minor" in w for w in wires), "a hold no entry settles": any(is_hold(w) and i not in settled for w, i in zip(wires, ids)), "a hold an entry settles": any(is_hold(w) and i in settled for w, i in zip(wires, ids)), "an allowed capture": any("captures" in w and w["verdict"] == "allow" for w in wires), "a refused capture": any("captures" in w and w["verdict"] == "deny" for w in wires), "a capture of an unsettled hold": any( "captures" in w and w["verdict"] == "allow" and w["captures"] not in settled for w in wires), "an allowed reclaim": any("reclaims" in w and w["verdict"] == "allow" for w in wires), "an allowed refund": any("refunded_minor" in w and w["verdict"] == "allow" for w in wires), "a refused refund stating no amount": any( w["verdict"] == "deny" and w.get("deny_reason") == "wrong-subject" and "refunded_minor" not in w for w in wires), "a receipt stating its revocation marker": any("known_through" in w for w in wires), "a receipt stating none": any("known_through" not in w for w in wires), } missing = [name for name, present in forms.items() if not present] if missing: raise Refused(f"the log no longer holds: {', '.join(missing)}") def selftest(doc, steward, key): faults = { "a reader restoring a captured hold's whole reservation": {"captured_less": False}, "a reader restoring a reclaimed hold's money": {"reclaim_releases": False}, "a reader subtracting a refund from the gross debit": {"refund_lowers_gross": True}, } for name, rules in faults.items(): try: check(doc, steward, key, **rules) except Refused: continue raise SystemExit(f"verify_lifecycle: selftest: {name} was not refused") # A tampered envelope must not verify. broken = json.loads(json.dumps(doc)) env = bytearray.fromhex(broken["entries"][1]["envelope_hex"]) env[-1] ^= 1 broken["entries"][1]["envelope_hex"] = env.hex() try: check(broken, steward, key) except Refused: return raise SystemExit("verify_lifecycle: selftest: a tampered envelope was not refused") def main(): args = sys.argv[1:] run_selftest = "--selftest" in args args = [a for a in args if a != "--selftest"] pack = args[0] if args else os.path.join(os.path.dirname(os.path.abspath(__file__)), "v1") with open(os.path.join(pack, "meta.json"), encoding="utf-8") as f: meta = json.load(f) with open(os.path.join(pack, "lifecycle.json"), encoding="utf-8") as f: doc = json.load(f) steward, key = meta["steward"], bytes.fromhex(meta["pubkey_hex"]) try: count = check(doc, steward, key) check_coverage(doc) except Refused as e: print(f"verify_lifecycle: {e}", file=sys.stderr) return 1 if run_selftest: selftest(doc, steward, key) print(f"verify_lifecycle: {count} entries read as published" + (", and every fault was refused" if run_selftest else "")) return 0 if __name__ == "__main__": sys.exit(main())