open-standards/jcp/conformance/verify_lifecycle.py
Transaction Science 01128aed3e
Some checks failed
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 6m0s
jcp: the hold's life gets a published log and a second reader, and the profile spans it
Five receipt members added in the last two days — `held_minor`,
`known_through`, `captures`, `reclaims`, `refunded_minor` — had no published
vector and no independent reader. By this repository's own bar that means their
canonical form was pinned by nothing but the implementation that wrote them.

`conformance/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, a refund refused to
its subject, and a point that states its revocation marker on some receipts and
not others. It publishes the joules, gross money and refunds a reader must
restore. `verify_lifecycle.py` reads it by the specification's rules, sharing
no code with the reference implementation, and restored the same ledger on its
first run. Its selftest re-reads the log with each rule broken — restoring a
captured hold's whole reservation, restoring a reclaimed hold's money,
subtracting a refund from the gross debit — and requires every one refused, so
a pass means the log distinguishes the rules. It runs in CI beside the replay
reader, and a Rust test reads the committed pack through the library.

Regenerating the vectors produced exactly one new file. Every existing pack
came out byte-identical, which is independent confirmation that none of the
receipt changes altered a published vector.

The x402 profile now spans the lifecycle, and this is where our side differs
from the wire it rides: **every capture seals its own measurement.** A stream
paid per chunk gets a receipt per chunk, each committing to that draw's amount
and the energy metered since the last — so a dispute over the third step has
the third step's evidence, not one total sealed afterwards. `capture`,
`reclaim` and `refund` are on the profile; ingest reads `auth-capture` and
`batch-settlement` and takes the capture deadline from the requirement; and the
refund goes through the runtime's rule, so the agent cannot refund itself.

Co-Authored-By: Claude <[email protected]>
2026-11-10 15:24:37 -05:00

182 lines
8 KiB
Python

#!/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())