From a5e1b3092bfb4b5975a1123a3bef545c3f7f3c36 Mon Sep 17 00:00:00 2001 From: Laxmikant Sharma Date: Tue, 8 Sep 2026 00:52:41 -0700 Subject: [PATCH 1/4] tools: a reference Facilitator, party agents, and a measurement harness Section 15 says no Facilitator, Buyer or Seller exchanging messages over the Section 12 endpoints is known to the author. That is now half wrong: this is one such implementation. Two independent ones settling each other's contracts is still the experiment Section 1.4 describes. facilitator.py serves the six operations of Table 1 over five paths, runs the Figure 2 state machine, evaluates the Section 7.2 constraint before locking funds, applies the Section 7.4 waterfall, issues a Facilitator signed attestation for every terminal contract, and refuses with RFC 9457 problem documents that name the rule. agents.py is the Buyer, Seller and Verifier side. measure.py drives three contracts to FINAL, SETTLED and ABANDONED, exercises eight refusal paths, and reports costs. Signatures are real Ed25519 over the Section 13.1 signing input. The committed examples keep their placeholder values on purpose: the published draft prints their digests in Section 14 and cannot be corrected, so re-signing them would desynchronise this repository from that document. measure.py mints fresh keys and fresh contracts instead, and they validate against the published schemas. Running it found two specification defects, both recorded in tools/README.md and both -02 items rather than code changes. Section 6 introduces the missed-deadline rule with "This is the rule that makes silence expensive", then slashes the bond only to the extent of restitution_basis. The worked example sets that to "released", and under the default on-verification release nothing is released before a verdict, so the extent is zero. A seller can sign, post a bond, deliver nothing, and get the entire bond back. Section 7.4 has the same hole. Rank 3 restitution is measured against the same member, so it is also zero, and the bond falls through to rank 5 and the neutral sink. That is the exact -00 behaviour Section 7.4 exists to correct, reproduced by the -01's own worked example. Setting the member to "price" pays the buyer 18.00 from the bond instead. validate.py is untouched and still passes its 66 checks; cryptography stays an optional dependency it does not need. Co-Authored-By: Claude Fable 5.1 --- tools/README.md | 83 ++++++ tools/agents.py | 217 ++++++++++++++ tools/facilitator.py | 679 +++++++++++++++++++++++++++++++++++++++++++ tools/measure.py | 395 +++++++++++++++++++++++++ tools/pactcore.py | 427 +++++++++++++++++++++++++++ 5 files changed, 1801 insertions(+) create mode 100644 tools/README.md create mode 100644 tools/agents.py create mode 100644 tools/facilitator.py create mode 100644 tools/measure.py create mode 100644 tools/pactcore.py diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..3fc93c0 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,83 @@ +# tools + +Four programs. The first checks the committed examples; the other three are a +working implementation of the protocol. + +| File | What it is | +|---|---| +| `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. | +| `pactcore.py` | Canonicalization, digests, JWS signing and verification, identifier normalization, the assurance constraint, and the RFC 9162 Merkle tree. | +| `facilitator.py` | A reference Facilitator: the six operations of Section 12 over five paths, the Figure 2 state machine, the Section 7.4 waterfall, and RFC 9457 refusals that name the rule. | +| `agents.py` | Buyer, Seller, Verifier and Challenger clients. | +| `measure.py` | Drives three contracts to the three terminal states, exercises the refusal paths, and reports what it costs. | + +``` +pip install jsonschema referencing # validate.py +pip install cryptography pytest # everything else +python3 tools/validate.py +python3 tools/measure.py +``` + +`cryptography` is optional and `validate.py` does not need it, so a checkout +still validates on a machine with nothing else installed. + +## What this does and does not answer + +Section 15 of the draft records that no Facilitator, Buyer or Seller exchanging +messages over the Section 12 endpoints was known to the author when -01 was +posted. This is that implementation, and it is one implementation. The +falsifiable experiment of Section 1.4 is *two independent* implementations +settling contracts through every terminal state, so this is the first half of +it and an invitation for the second. + +The signatures are real Ed25519. The contracts `measure.py` mints are fresh and +so are the keys, deliberately: the committed examples under `examples/` keep +their placeholder signature values because the published Internet-Draft prints +their digests in Section 14 and cannot be corrected, so re-signing them would +silently desynchronise this repository from that document. Real keys and +recomputed digests belong together in a -02. + +No payment rail is touched. Section 1.2 puts the rail out of scope and +`price.settlement` names a binding; money here is an integer number of cents in +three pools. What is real is the object flow, the state machine, the signature +verification and the arithmetic. + +## Two defects this implementation found + +Both are in the specification, not in the code, and both are -02 items. + +**The rule that is supposed to make silence expensive makes it free.** Section 6 +says "This is the rule that makes silence expensive: under the -00 the cheapest +attack was to deliver nothing verifiable and be paid anyway", and then requires +that on a missed deadline the Facilitator "slash the Bond to the extent of +`liability.restitution_basis`". The worked example sets `restitution_basis` to +`released`, and under the default `on-verification` release nothing is released +before a Verdict, so the extent is zero. Run it and the Seller signs, posts a +bond, delivers nothing, and gets the whole bond back: + +``` +locked escrow 180.00, bond 18.00, fund 0.50 +deadline passed with no Delivery, returned escrow 180.00 to buyer +restitution basis 'released' gives 0.00 from bond +returned bond 18.00 to seller +``` + +**A defrauded buyer still recovers nothing from the bond.** Section 7.4 reorders +the waterfall to pay restitution before any bounty, and says of the -00 that +because the remainder went to a neutral sink "a defrauded Buyer recovered +nothing". Under `restitution_basis: released` the amount owed at rank 3 is again +zero, so the bond falls through to rank 5 and lands on the same sink: + +``` +rank 1: reversed unreleased escrow 180.00 to buyer +rank 3: restitution basis 'released' owed 0.00, paid 0.00 from bond +rank 5: remainder 18.00 directed to sink +``` + +Change one enum to `price` and the same fraud pays the buyer 18.00 out of the +bond. Same protocol, same fraud, opposite outcome for the injured party, decided +by a member whose default the document never argues for. + +The fix is a -02 question, not a code change: either the worked example should +use `price`, or `restitution_basis` needs a stated default and a rule that a +Facilitator refuses a combination that makes the remedy vacuous. diff --git a/tools/agents.py b/tools/agents.py new file mode 100644 index 0000000..c12398a --- /dev/null +++ b/tools/agents.py @@ -0,0 +1,217 @@ +"""Buyer, Seller, Verifier and Challenger: the client half of the reference pair. + +Each party holds one key and speaks HTTP to a Facilitator. Nothing here trusts +the Facilitator's word for anything it can check itself: a party verifies the +attestation it is handed, and recomputes the contract digest rather than +accepting the one it is told. + +The contracts these agents mint are fresh, with fresh keys and real signatures. +They are deliberately NOT the committed examples under examples/, whose digests +the published Internet-Draft prints in Section 14 and cannot be corrected. +""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any + +import pactcore as pc + +MEDIA_CONTRACT = "application/pact-contract+json" +MEDIA_DELIVERY = "application/pact-delivery+json" +MEDIA_VERDICT = "application/pact-verdict+json" +MEDIA_CHALLENGE = "application/pact-challenge+json" + + +@dataclass +class Wire: + """A record of one request and response, for the measurement harness.""" + method: str + path: str + status: int + request_bytes: int + response_bytes: int + seconds: float + + +class Client: + def __init__(self, base: str) -> None: + self.base = base.rstrip("/") + self.wire: list[Wire] = [] + + def _call(self, method: str, path: str, body: dict | None, + content_type: str | None) -> tuple[int, dict]: + raw = json.dumps(body, separators=(",", ":")).encode() if body else None + req = urllib.request.Request(self.base + path, data=raw, method=method) + if content_type: + req.add_header("Content-Type", content_type) + started = time.perf_counter() + try: + with urllib.request.urlopen(req) as resp: + out = resp.read() + status = resp.status + except urllib.error.HTTPError as exc: + out = exc.read() + status = exc.code + elapsed = time.perf_counter() - started + self.wire.append(Wire(method, path, status, len(raw or b""), len(out), elapsed)) + return status, json.loads(out or b"{}") + + def post(self, path: str, body: dict, ct: str) -> tuple[int, dict]: + return self._call("POST", path, body, ct) + + def get(self, path: str) -> tuple[int, dict]: + return self._call("GET", path, None, None) + + # -- named operations, Table 1 ---------------------------------------- + def propose(self, vtc): return self.post("/pact/v1/contracts", vtc, MEDIA_CONTRACT) + def deliver(self, d): return self.post("/pact/v1/deliveries", d, MEDIA_DELIVERY) + def verdict(self, v): return self.post("/pact/v1/verdicts", v, MEDIA_VERDICT) + def challenge(self, c): return self.post("/pact/v1/challenges", c, MEDIA_CHALLENGE) + def contract(self, vid): return self.get(f"/pact/v1/contracts/{vid}") + def attestation(self, vid): return self.get(f"/pact/v1/attestations/{vid}") + def capability(self): return self.get("/.well-known/pact-facilitator") + + +@dataclass +class Party: + did: str + key: pc.Key + client: Client + + def sign_into(self, obj: dict, typ: str, array: bool = False) -> dict: + return pc.attach(obj, pc.sign(obj, self.key, typ), array) + + +def make_party(did: str, resolver: pc.KeyResolver, client: Client, + alg: str = "EdDSA") -> Party: + key = resolver.register(pc.Key.generate(f"{did}#key-1", alg)) + return Party(did=did, key=key, client=client) + + +# -------------------------------------------------------------------------- +# Contract construction +# -------------------------------------------------------------------------- + +def draft_contract(vid: str, buyer: str, seller: str, facilitator: str, + verifier: str, *, price: str = "180.00", bond: str = "18.00", + fund: str = "0.50", q_min: float = 0.9091, + deadline: str = "2027-01-01T00:00:00Z", + release: str = "on-verification", + restitution_basis: str = "released", + spec_hash: str | None = None, + criteria_hash: str | None = None) -> dict: + """An unsigned contract in the shape schemas/vtc.schema.json requires.""" + return { + "pact": "0.1", + "type": "VerifiableTaskContract", + "id": vid, + "parties": { + "buyer": buyer, "seller": seller, + "facilitator": facilitator, "verifier": verifier, + }, + "task": { + "spec_hash": spec_hash or pc.h(b"taskspec placeholder"), + "spec_uri": "https://buyer.example/specs/taskspec.json", + "deadline": deadline, + }, + "price": { + "amount": price, "currency": "USDC", + "settlement": "pact-escrow", "network": "eip155:8453", + }, + "verification": { + "tier": "T0-reexec", "profile": "acceptance", + "criteria_hash": criteria_hash or pc.h(b"criteria placeholder"), + }, + "assurance": {"mode": "certain", "q_min": q_min}, + "release": release, + "liability": { + "seller_bond": bond, "verification_fund": fund, + "cap": price, "restitution_basis": restitution_basis, + }, + "challenge": {"window_seconds": 3600, "max_dispute_seconds": 86400}, + } + + +def cosign(vtc: dict, buyer: Party, seller: Party) -> dict: + """Both parties sign the same bytes: the contract without its signatures. + + That is what makes the digest meaningful. vtc_hash is then taken over the + contract WITH the signature set, so the commitment proves who agreed. + """ + entries = [pc.sign(vtc, buyer.key, MEDIA_CONTRACT), + pc.sign(vtc, seller.key, MEDIA_CONTRACT)] + vtc["signatures"] = entries + return vtc + + +def make_delivery(vtc: dict, seller: Party, work: bytes, + results: bytes) -> dict: + d = { + "pact": "0.1", + "type": "Delivery", + "vtc_id": vtc["id"], + "vtc_hash": pc.digest_over(pc.hashable(vtc)), + "work_hash": pc.h(work), + "work_uri": "https://cdn.seller.example/o/" + pc.h(work)[7:15], + "input_hash": pc.h(b"inputs"), + "delivered_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "evidence": { + "profile": "acceptance", + "instrument_hash": vtc["verification"]["criteria_hash"], + "results_hash": pc.h(results), + "results_uri": "https://cdn.seller.example/r/" + pc.h(results)[7:15], + }, + } + return seller.sign_into(d, MEDIA_DELIVERY) + + +def make_verdict(vtc: dict, delivery: dict, verifier: Party, + outcome: str) -> dict: + v = { + "pact": "0.1", + "type": "Verdict", + "vtc_id": vtc["id"], + "delivery_hash": pc.digest_over(pc.hashable(delivery)), + "outcome": outcome, + "profile": "acceptance", + "instrument_hash": vtc["verification"]["criteria_hash"], + "results_hash": delivery["evidence"]["results_hash"], + "evaluated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + return verifier.sign_into(v, MEDIA_VERDICT) + + +def make_challenge(vtc: dict, delivery: dict, challenger: Party, + failing: list[str]) -> dict: + c = { + "pact": "0.1", + "type": "Challenge", + "vtc_id": vtc["id"], + "delivery_hash": pc.digest_over(pc.hashable(delivery)), + "proof": { + "profile": "acceptance", + "instrument_hash": vtc["verification"]["criteria_hash"], + "results_hash": pc.h(b"independent re-execution"), + "results_uri": "https://watch.example/o/a91e", + "failing_checks": failing, + }, + } + return challenger.sign_into(c, MEDIA_CHALLENGE) + + +def check_attestation(att: dict, resolver: pc.KeyResolver, + facilitator: str) -> tuple[bool, str]: + """A party checks the record it is handed rather than taking it on trust. + + Section 11 makes the Facilitator the required signer precisely so that a + slashed Seller cannot decline to co-sign its own conviction. The other side + of that is that the Facilitator's signature is what makes the record + evidence, so it has to actually verify. + """ + return pc.verify_object(att, resolver, + "application/pact-attestation+json", [facilitator]) diff --git a/tools/facilitator.py b/tools/facilitator.py new file mode 100644 index 0000000..0e858ba --- /dev/null +++ b/tools/facilitator.py @@ -0,0 +1,679 @@ +"""A reference Facilitator: the six operations of Section 12 over five paths. + +This is the first implementation that speaks the protocol. Section 15 of the +-01 records that no Facilitator, Buyer or Seller exchanging messages over +these endpoints was known to the author when the draft was posted; this file +and agents.py are the answer to that, and the count of independent +implementations is still one, which is not the falsifiable experiment of +Section 1.4. Two independent implementations settling each other's contracts +is that experiment. This is the first half of it. + +What it enforces, with the section each rule comes from, is listed in RULES +below. Every refusal is an RFC 9457 problem document naming the rule that was +violated, because a Facilitator that refuses without saying why cannot be +debugged against by a second implementer. + +Storage is in memory. Money is an integer number of cents in three pools. No +payment rail is touched: Section 1.2 puts the rail out of scope, and a +settlement binding names one. What is real here is the object flow, the state +machine, the signature verification and the arithmetic. + +Run it: + + python3 tools/facilitator.py --port 8402 + +Then drive it with agents.py, or measure it with measure.py. +""" + +from __future__ import annotations + +import argparse +import json +import re +import threading +import time +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pactcore as pc + +RULES = """ +Section 5.3 liability is REQUIRED, and a contract without it is not a PACT contract +Section 7.1 cumulative release before a recorded Verdict MUST NOT exceed the Bond +Section 7.2 the assurance constraint is evaluated BEFORE funds lock, and a contract + that fails it is refused +Section 7.4 the five-rank remedy waterfall, restitution before bounty or remainder +Section 7.5 the Bond is returned when the contract reaches FINAL or SETTLED +Section 6 deadline expiry with no Delivery moves the contract to ABANDONED +Section 9.1 verifier independence is DERIVED by comparing normalized party + identifiers, never read from a field +Section 3 a Facilitator MUST NOT act as Verifier for a contract it settles +Section 11 an attestation is issued for every terminal contract, is signed by the + Facilitator, and does not require the signature of the party whose loss + it records +Section 12.2 a POST whose body canonicalizes to a known hash returns 200 and the + existing resource; the same id with a different hash returns 409 +Section 12.3 every failure is an RFC 9457 problem document naming the rule +Section 12.4 a Verdict signer MUST satisfy Section 9.1, and a Verdict for a contract + with no recorded Delivery is rejected +Section 13.1 JWS with a detached payload, an algorithm allowlist, and kid inside the + protected header +""" + +PROBLEM_BASE = "https://pact-spec.github.io/problems/" + +# Section 18.5 reserves these names but does not create a registry; the draft +# asks IANA to create one on publication. Until then these are the document's +# own strings and are stable within this implementation. +PROBLEMS = { + "assurance-constraint-unsatisfied": (422, "Section 7.2"), + "liability-missing": (422, "Section 5.3"), + "parties-not-distinct": (422, "Section 9.1"), + "signature-invalid": (401, "Section 13.1"), + "signature-missing": (401, "Section 13.1"), + "verifier-not-independent": (422, "Section 9.1"), + "facilitator-cannot-verify": (422, "Section 3"), + "no-recorded-delivery": (409, "Section 12.4"), + "wrong-state": (409, "Section 12"), + "object-conflict": (409, "Section 12.2"), + "challenge-window-closed": (409, "Section 7.4"), + "release-exceeds-bond": (422, "Section 7.1"), + "unknown-contract": (404, "Section 12"), +} + +TERMINAL = ("FINAL", "SETTLED", "ABANDONED") + + +class Refuse(Exception): + def __init__(self, kind: str, detail: str, **extra: Any) -> None: + self.kind = kind + self.detail = detail + self.extra = extra + super().__init__(detail) + + +@dataclass +class Contract: + vtc: dict + state: str = "PROPOSED" + pools: pc.Pools = field(default_factory=pc.Pools) + delivery: dict | None = None + verdict: dict | None = None + challenge: dict | None = None + attestation: dict | None = None + window_opened_at: float | None = None + created_at: float = field(default_factory=time.time) + + @property + def id(self) -> str: + return self.vtc["id"] + + @property + def buyer(self) -> str: + return self.vtc["parties"]["buyer"] + + @property + def seller(self) -> str: + return self.vtc["parties"]["seller"] + + def digest(self) -> str: + return pc.digest_over(pc.hashable(self.vtc)) + + +class Facilitator: + """The settlement service. Thread safe under the threading HTTP server.""" + + def __init__(self, identity: str, key: pc.Key, resolver: pc.KeyResolver, + now: Any = time.time) -> None: + self.identity = identity + self.key = key + self.resolver = resolver + self.now = now + self.contracts: dict[str, Contract] = {} + self.seen: dict[str, tuple[str, dict]] = {} # object digest -> (kind, body) + self.lock = threading.RLock() + self.message_count = 0 + + # -- Section 12.2 ------------------------------------------------------ + def _idempotent(self, kind: str, obj: dict) -> dict | None: + digest = pc.digest_over(pc.hashable(obj)) + hit = self.seen.get(digest) + if hit is not None: + return hit[1] + return None + + def _remember(self, kind: str, obj: dict, body: dict) -> None: + self.seen[pc.digest_over(pc.hashable(obj))] = (kind, body) + + # -- Propose, Section 12.1 -------------------------------------------- + def propose(self, vtc: dict) -> tuple[int, dict]: + with self.lock: + existing = self._idempotent("contract", vtc) + if existing is not None: + return 200, existing + + vid = vtc.get("id") + if vid in self.contracts: + raise Refuse("object-conflict", + f"contract {vid} exists with a different hash") + + liability = vtc.get("liability") + if not liability: + raise Refuse("liability-missing", + "a contract that does not allocate liability is not " + "a PACT contract") + + buyer = vtc["parties"]["buyer"] + seller = vtc["parties"]["seller"] + if pc.same_party(buyer, seller): + raise Refuse("parties-not-distinct", + "buyer and seller are the same party after the " + "normalization of Section 9.1", + buyer=buyer, seller=seller) + + ok, why = pc.verify_object(vtc, self.resolver, + "application/pact-contract+json", + [buyer, seller]) + if not ok: + raise Refuse("signature-invalid", why) + + price = float(vtc["price"]["amount"]) + bond = float(liability["seller_bond"]) + fund = float(liability["verification_fund"]) + q_min = float(vtc["assurance"]["q_min"]) + + # Section 7.2: evaluated BEFORE funds lock. This is the one check + # that makes a bond size a claim a Facilitator can refuse rather + # than a number a Seller asserts. + if not pc.assurance_holds(price, bond, q_min, released=0.0): + need = pc.required_bond(price, q_min, 0.0) + raise Refuse( + "assurance-constraint-unsatisfied", + f"Bond {bond:.2f} is below the minimum {need:.2f} required " + f"for q_min {q_min:.2f} at price {price:.2f} with E 0.00.", + required_bond=f"{need:.2f}", declared_bond=f"{bond:.2f}", + q_min=q_min, price=f"{price:.2f}") + + c = Contract(vtc=vtc) + c.pools.escrow = pc.cents(price) + c.pools.bond = pc.cents(bond) + c.pools.bond_initial = pc.cents(bond) + c.pools.fund = pc.cents(fund) + c.pools.note(f"locked escrow {price:.2f}, bond {bond:.2f}, fund {fund:.2f}") + c.state = "FUNDED" + self.contracts[c.id] = c + body = self._contract_body(c) + self._remember("contract", vtc, body) + return 201, body + + def _contract_body(self, c: Contract) -> dict: + out = dict(c.vtc) + out["state"] = c.state # Section 12: added here, never signed or hashed + return out + + # -- Retrieve ---------------------------------------------------------- + def get_contract(self, vid: str) -> tuple[int, dict]: + with self.lock: + c = self.contracts.get(vid) + if c is None: + raise Refuse("unknown-contract", f"no contract {vid}") + self._expire_if_due(c) + return 200, self._contract_body(c) + + # -- Section 6: deadline expiry --------------------------------------- + def _expire_if_due(self, c: Contract) -> None: + if c.state != "FUNDED": + return + deadline = c.vtc.get("task", {}).get("deadline") + if not deadline: + return + due = time.mktime(time.strptime(deadline, "%Y-%m-%dT%H:%M:%SZ")) + if self.now() < due: + return + # Nothing was delivered and the deadline passed. The escrow goes back + # and the Bond is slashed to the extent of restitution_basis. Under + # basis "released" with nothing released that is zero, which is the + # honest reading of the current example and a live -02 question. + c.pools.paid_to_buyer += c.pools.escrow + c.pools.note(f"deadline {deadline} passed with no Delivery, " + f"returned escrow {pc.money(c.pools.escrow)} to buyer") + c.pools.escrow = 0 + basis = c.vtc["liability"]["restitution_basis"] + owed = c.pools.released if basis == "released" else pc.cents(c.vtc["price"]["amount"]) + slashed = min(owed, c.pools.bond) + c.pools.bond -= slashed + c.pools.paid_to_buyer += slashed + c.pools.restituted += slashed + c.pools.note(f"restitution basis {basis!r} gives {pc.money(slashed)} from bond") + self._return_bond(c) + c.state = "ABANDONED" + self._attest(c, outcome="abandoned") + + def _return_bond(self, c: Contract) -> None: + # Section 7.5: the Bond is returned on finality. The -00 had no rule + # returning it at all. + if c.pools.bond: + c.pools.note(f"returned bond {pc.money(c.pools.bond)} to seller") + c.pools.bond_returned += c.pools.bond + c.pools.bond = 0 + if c.pools.fund: + c.pools.note(f"returned verification fund {pc.money(c.pools.fund)}") + c.pools.fund = 0 + + # -- Submit Delivery, Section 12 -------------------------------------- + def submit_delivery(self, dlv: dict) -> tuple[int, dict]: + with self.lock: + existing = self._idempotent("delivery", dlv) + if existing is not None: + return 200, existing + + c = self._contract_for(dlv) + self._expire_if_due(c) + if c.state != "FUNDED": + raise Refuse("wrong-state", + f"contract {c.id} is {c.state}, a Delivery is only " + f"accepted in FUNDED") + + # A Delivery not signed by the contract's Seller is rejected. + ok, why = pc.verify_object(dlv, self.resolver, + "application/pact-delivery+json", [c.seller]) + if not ok: + raise Refuse("signature-invalid", why) + + c.delivery = dlv + c.state = "DELIVERED" + body = dict(dlv) + body["state"] = c.state + self._remember("delivery", dlv, body) + return 202, body + + def _contract_for(self, obj: dict) -> Contract: + """Resolve the contract an object refers to, and check its commitment. + + The objects commit differently and the schemas say so. A Delivery + carries vtc_hash and commits to the contract. A Verdict and a Challenge + carry delivery_hash and commit to the Delivery being judged, which is + the right thing to bind: a Verdict is a statement about a Delivery. + """ + c = self.contracts.get(obj.get("vtc_id")) + if c is None: + raise Refuse("unknown-contract", f"no contract {obj.get('vtc_id')}") + + if "vtc_hash" in obj: + if obj["vtc_hash"] != c.digest(): + raise Refuse("object-conflict", + "vtc_hash does not commit to this contract", + expected=c.digest(), received=obj["vtc_hash"]) + elif "delivery_hash" in obj: + if c.delivery is None: + raise Refuse("no-recorded-delivery", + f"contract {c.id} has no recorded Delivery to judge") + recorded = pc.digest_over(pc.hashable(c.delivery)) + if obj["delivery_hash"] != recorded: + raise Refuse("object-conflict", + "delivery_hash does not commit to the recorded Delivery", + expected=recorded, received=obj["delivery_hash"]) + return c + + # -- Record Verdict, Section 12.4 ------------------------------------- + def record_verdict(self, verdict: dict) -> tuple[int, dict]: + with self.lock: + existing = self._idempotent("verdict", verdict) + if existing is not None: + return 200, existing + + c = self._contract_for(verdict) + if c.state != "DELIVERED": + raise Refuse("wrong-state", f"contract {c.id} is {c.state}") + + kids = pc.signer_kids(verdict) + if not kids: + raise Refuse("signature-missing", "the Verdict carries no signature") + + # Section 9.1, derived not declared. A field that says independent + # is satisfied by typing the word. + for kid in kids: + if pc.kid_covers(kid, c.seller): + raise Refuse("verifier-not-independent", + "the Verdict is signed by the contract's Seller", + signer=kid, seller=c.seller) + if pc.kid_covers(kid, self.identity): + raise Refuse("facilitator-cannot-verify", + "a Facilitator MUST NOT act as Verifier for a " + "contract it settles", + signer=kid, facilitator=self.identity) + + ok, why = pc.verify_object(verdict, self.resolver, + "application/pact-verdict+json", []) + if not ok: + raise Refuse("signature-invalid", why) + + c.verdict = verdict + outcome = verdict["outcome"] + if outcome == "PASS": + mode = c.vtc["release"] + if mode == "on-verification": + c.state = "RELEASING" + self._settle_pass(c) + else: + c.state = "RELEASING" + c.window_opened_at = self.now() + else: + c.state = "DISPUTED" + self._apply_waterfall(c, challenger=c.buyer, costs=0) + body = dict(verdict) + body["state"] = c.state + self._remember("verdict", verdict, body) + return 201, body + + def _settle_pass(self, c: Contract) -> None: + c.pools.paid_to_seller += c.pools.escrow + c.pools.note(f"released escrow {pc.money(c.pools.escrow)} to seller on PASS") + c.pools.escrow = 0 + self._return_bond(c) + c.state = "FINAL" + self._attest(c, outcome="performed") + + # -- Open challenge, Section 7.4 -------------------------------------- + def open_challenge(self, ch: dict) -> tuple[int, dict]: + with self.lock: + existing = self._idempotent("challenge", ch) + if existing is not None: + return 200, existing + + c = self._contract_for(ch) + if c.state not in ("RELEASING", "DELIVERED"): + raise Refuse("wrong-state", + f"contract {c.id} is {c.state}, no challenge window " + f"is open") + window = int(c.vtc.get("challenge", {}).get("window_seconds", 0)) + if c.window_opened_at is not None and \ + self.now() - c.window_opened_at > window: + raise Refuse("challenge-window-closed", + f"the {window}s challenge window has closed") + + ok, why = pc.verify_object(ch, self.resolver, + "application/pact-challenge+json", []) + if not ok: + raise Refuse("signature-invalid", why) + + c.challenge = ch + c.state = "DISPUTED" + + # The Challenge object carries no member for the documented costs + # that waterfall rank 2 must reimburse, and the schema is closed, + # so a Facilitator has nothing in the object to reimburse against. + # Implementing this surfaced the gap. Until a -02 adds a cost claim, + # the honest reading is that the Verification Fund is sized for one + # challenge under the contract's profile and is spent on one, so + # that is what this implementation does. Recorded in the ledger. + challenger = pc.signer_kids(ch)[0] if pc.signer_kids(ch) else c.buyer + costs = c.pools.fund + c.pools.note("rank 2 note: the Challenge object defines no documented " + "cost member, so the whole Verification Fund is treated " + "as the sized reimbursement. This is a -02 gap.") + self._apply_waterfall(c, challenger=challenger, costs=costs) + body = dict(ch) + body["state"] = c.state + self._remember("challenge", ch, body) + return 202, body + + # -- Section 7.4, the five ranks in order ------------------------------ + def _apply_waterfall(self, c: Contract, challenger: str, costs: int) -> None: + p = c.pools + + # 1. Reverse any unreleased escrow to the Buyer. + if p.escrow: + p.paid_to_buyer += p.escrow + p.note(f"rank 1: reversed unreleased escrow {pc.money(p.escrow)} to buyer") + p.escrow = 0 + + # 2. Reimburse the Challenger's documented costs FROM THE VERIFICATION + # FUND. Paying this from the Bond is what made the -00 rule + # unsatisfiable, since proving fraud costs about what the work cost. + if costs: + paid = min(costs, p.fund) + p.fund -= paid + p.paid_to_challenger += paid + p.note(f"rank 2: reimbursed challenger {pc.money(paid)} from the " + f"verification fund") + if paid < costs: + p.note(f"rank 2: verification fund short by " + f"{pc.money(costs - paid)}, which is a sizing failure " + f"and not a protocol one") + + # 3. Restore the Buyer from the Bond, up to restitution_basis. + basis = c.vtc["liability"]["restitution_basis"] + cap = pc.cents(c.vtc["liability"]["cap"]) + owed = p.released if basis == "released" else pc.cents(c.vtc["price"]["amount"]) + owed = min(owed, cap) + restitution = min(owed, p.bond) + if restitution: + p.bond -= restitution + p.paid_to_buyer += restitution + p.restituted += restitution + p.note(f"rank 3: restitution basis {basis!r} owed {pc.money(owed)}, " + f"paid {pc.money(restitution)} from bond") + + # 4. Pay the Challenger bounty from the remaining Bond. + bounty = min(p.bond, owed) if p.bond else 0 + if bounty: + p.bond -= bounty + p.paid_to_challenger += bounty + p.note(f"rank 4: bounty {pc.money(bounty)} from the remaining bond") + + # 5. Direct any remainder per liability.remainder_to. + remainder_to = c.vtc["liability"].get("remainder_to", "sink") + if p.bond: + if remainder_to == "buyer": + p.paid_to_buyer += p.bond + else: + p.remainder += p.bond + p.note(f"rank 5: remainder {pc.money(p.bond)} directed to {remainder_to}") + p.bond = 0 + + if p.fund: + p.note(f"returned unspent verification fund {pc.money(p.fund)}") + p.fund = 0 + + c.state = "SETTLED" + self._attest(c, outcome="slashed") + + # -- Section 11, the Work Attestation --------------------------------- + def _attest(self, c: Contract, outcome: str) -> None: + """Issued for every terminal contract, signed by the Facilitator alone. + + Under the -00 a slashed Seller simply declined to co-sign its own + conviction, which made the reputation layer structurally incapable of + recording a negative outcome. The Seller does not consent to this + record and its consent is not required. + """ + att = { + "pact": "0.1", + "type": "WorkAttestation", + "vtc_id": c.id, + "vtc_hash": c.digest(), + "parties": {"buyer": c.buyer, "seller": c.seller, + "facilitator": self.identity}, + "subject": c.seller, + "role": "seller", + "outcome": outcome, + "amounts": { + "settled": pc.money(c.pools.paid_to_seller), + # Restitution is what the Buyer recovered FROM THE BOND. Escrow + # coming back is the Buyer's own money and is not restitution; + # conflating the two is how the -00 was able to look solvent. + "restituted": pc.money(c.pools.restituted), + "slashed": pc.money(c.pools.bond_initial - c.pools.bond_returned + - c.pools.bond), + "currency": c.vtc["price"]["currency"], + }, + "opened_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", + time.gmtime(c.created_at)), + "settled_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", + time.gmtime(self.now())), + } + if c.delivery is not None: + att["work_hash"] = c.delivery["work_hash"] + att["signatures"] = [pc.sign(att, self.key, + "application/pact-attestation+json")] + c.attestation = att + + def get_attestation(self, vid: str) -> tuple[int, dict]: + with self.lock: + c = self.contracts.get(vid) + if c is None: + raise Refuse("unknown-contract", f"no contract {vid}") + self._expire_if_due(c) + if c.attestation is None: + raise Refuse("wrong-state", + f"contract {vid} is {c.state} and not terminal, so " + f"no attestation exists yet") + return 200, c.attestation + + def capability_document(self) -> dict: + return { + "pact": "0.1", + "facilitator": self.identity, + "endpoints": { + "contract": "/pact/v1/contracts", + "delivery": "/pact/v1/deliveries", + "verdict": "/pact/v1/verdicts", + "challenge": "/pact/v1/challenges", + "attestation": "/pact/v1/attestations", + }, + "release_modes": ["on-verification"], + "assurance_modes": ["certain"], + "profiles": ["acceptance"], + "max_contract_value": "50000.00", + "currencies": ["USDC"], + "challenge_deposit": "0.00", + } + + +# -------------------------------------------------------------------------- +# HTTP +# -------------------------------------------------------------------------- + +ROUTES = { + "contracts": "propose", + "deliveries": "submit_delivery", + "verdicts": "record_verdict", + "challenges": "open_challenge", +} + +MEDIA = { + "propose": "application/pact-contract+json", + "submit_delivery": "application/pact-delivery+json", + "record_verdict": "application/pact-verdict+json", + "open_challenge": "application/pact-challenge+json", +} + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "pact-reference-facilitator/0.1" + + @property + def fac(self) -> Facilitator: + return self.server.facilitator # type: ignore[attr-defined] + + def log_message(self, fmt: str, *args: Any) -> None: + if getattr(self.server, "verbose", False): # type: ignore[attr-defined] + super().log_message(fmt, *args) + + def _send(self, status: int, body: dict, content_type: str) -> None: + raw = json.dumps(body, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + self.fac.message_count += 1 + + def _problem(self, exc: Refuse) -> None: + status, section = PROBLEMS.get(exc.kind, (400, "Section 12.3")) + body = { + "type": PROBLEM_BASE + exc.kind, + "title": exc.kind.replace("-", " "), + "status": status, + "detail": exc.detail, + # Section 12.3: errors MUST name the rule that was violated. + "section": section, + } + body.update(exc.extra) + self._send(status, body, "application/problem+json") + + def do_GET(self) -> None: + try: + if self.path == "/.well-known/pact-facilitator": + self._send(200, self.fac.capability_document(), + "application/pact-facilitator+json") + return + m = re.match(r"^/pact/v1/(contracts|attestations)/([^/]+)$", self.path) + if not m: + raise Refuse("unknown-contract", f"no route for {self.path}") + kind, vid = m.groups() + if kind == "contracts": + status, body = self.fac.get_contract(vid) + self._send(status, body, "application/pact-contract+json") + else: + status, body = self.fac.get_attestation(vid) + self._send(status, body, "application/pact-attestation+json") + except Refuse as exc: + self._problem(exc) + except Exception as exc: # pragma: no cover + self._problem(Refuse("wrong-state", f"{type(exc).__name__}: {exc}")) + + def do_POST(self) -> None: + try: + m = re.match(r"^/pact/v1/(contracts|deliveries|verdicts|challenges)$", + self.path) + if not m: + raise Refuse("unknown-contract", f"no route for {self.path}") + op = ROUTES[m.group(1)] + length = int(self.headers.get("Content-Length", 0)) + obj = json.loads(self.rfile.read(length) or b"{}") + status, body = getattr(self.fac, op)(obj) + self._send(status, body, MEDIA[op]) + except Refuse as exc: + self._problem(exc) + except Exception as exc: # pragma: no cover + self._problem(Refuse("wrong-state", f"{type(exc).__name__}: {exc}")) + + +def serve(facilitator: Facilitator, port: int = 8402, + verbose: bool = False) -> ThreadingHTTPServer: + httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler) + httpd.facilitator = facilitator # type: ignore[attr-defined] + httpd.verbose = verbose # type: ignore[attr-defined] + return httpd + + +def build(identity: str = "did:web:settle.example") -> tuple[Facilitator, pc.KeyResolver]: + resolver = pc.KeyResolver() + key = resolver.register(pc.Key.generate(identity + "#key-1")) + return Facilitator(identity, key, resolver), resolver + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--port", type=int, default=8402) + ap.add_argument("--verbose", action="store_true") + ap.add_argument("--rules", action="store_true", + help="print the rules this implementation enforces and exit") + args = ap.parse_args() + if args.rules: + print(RULES.strip()) + return + fac, _ = build() + httpd = serve(fac, args.port, args.verbose) + print(f"reference Facilitator {fac.identity} on http://127.0.0.1:{args.port}") + print(f"capability document at " + f"http://127.0.0.1:{args.port}/.well-known/pact-facilitator") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tools/measure.py b/tools/measure.py new file mode 100644 index 0000000..c728f18 --- /dev/null +++ b/tools/measure.py @@ -0,0 +1,395 @@ +"""Drive the reference pair through every terminal state and report what it costs. + +Three contracts, because FINAL, SETTLED and ABANDONED are mutually exclusive +branches of Figure 2 and one contract cannot reach all three. Each is minted +fresh with real Ed25519 keys, validated against the published schemas, settled +over HTTP against tools/facilitator.py, and its attestation verified by the +party that receives it. + +Then a set of refusals, because a settlement service is defined as much by what +it refuses as by what it accepts, and then microbenchmarks for the operations +that scale with traffic. + + python3 tools/measure.py # human readable + python3 tools/measure.py --json # machine readable + +Every number is produced on the machine that runs this, and the report names +that machine. Numbers quoted anywhere else should carry the same hardware line. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import platform +import statistics +import subprocess +import sys +import threading +import time + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import agents +import facilitator as fac_mod +import pactcore as pc + +ROOT = pathlib.Path(__file__).resolve().parent.parent +BUYER = "did:web:buyer.example:agents:procure-1" +SELLER = "did:web:dataforge.example:agents:etl-3" +VERIFIER = "did:web:audit.example" +FACILITATOR = "did:web:settle.example" + +REPORT: dict = {"scenarios": {}, "refusals": {}, "micro": {}, "environment": {}} + + +def _cpu_name() -> str: + """platform.processor() returns "i386" on macOS, which tells a reader nothing.""" + try: + out = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, text=True, timeout=5) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except Exception: + pass + return platform.processor() or platform.machine() + + +def _schema_check(obj: dict, schema_file: str) -> str: + """Validate a minted object against the published schema, if jsonschema is here.""" + try: + from jsonschema import Draft202012Validator + from referencing import Registry, Resource + except ImportError: # pragma: no cover + return "skipped, jsonschema not installed" + schemas = {} + for p in (ROOT / "schemas").glob("*.schema.json"): + schemas[p.name] = json.loads(p.read_text()) + registry = Registry().with_resources( + [(name, Resource.from_contents(s)) for name, s in schemas.items()]) + v = Draft202012Validator(schemas[schema_file], registry=registry) + errors = sorted(v.iter_errors(obj), key=lambda e: e.path) + return "valid" if not errors else f"INVALID: {errors[0].message}" + + +class Harness: + def __init__(self, port: int) -> None: + self.fac, self.resolver = fac_mod.build(FACILITATOR) + self.httpd = fac_mod.serve(self.fac, port) + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + self.base = f"http://127.0.0.1:{port}" + self.client = agents.Client(self.base) + self.buyer = agents.make_party(BUYER, self.resolver, self.client) + self.seller = agents.make_party(SELLER, self.resolver, self.client) + self.verifier = agents.make_party(VERIFIER, self.resolver, self.client) + + def stop(self) -> None: + self.httpd.shutdown() + self.httpd.server_close() + + def fresh(self, vid: str, **kw) -> dict: + vtc = agents.draft_contract(vid, BUYER, SELLER, FACILITATOR, VERIFIER, **kw) + return agents.cosign(vtc, self.buyer, self.seller) + + +def scenario(h: Harness, name: str, run) -> dict: + before = len(h.client.wire) + t0 = time.perf_counter() + detail = run() + elapsed = time.perf_counter() - t0 + wire = h.client.wire[before:] + out = { + "terminal_state": detail["state"], + "messages": len(wire), + "request_bytes": sum(w.request_bytes for w in wire), + "response_bytes": sum(w.response_bytes for w in wire), + "wall_ms": round(elapsed * 1000, 2), + "per_message": [ + {"op": f"{w.method} {w.path}", "status": w.status, + "req": w.request_bytes, "resp": w.response_bytes, + "ms": round(w.seconds * 1000, 2)} for w in wire], + } + out.update({k: v for k, v in detail.items() if k != "state"}) + REPORT["scenarios"][name] = out + return out + + +# -------------------------------------------------------------------------- +# The three terminal states +# -------------------------------------------------------------------------- + +def run_final(h: Harness) -> dict: + vtc = h.fresh("vtc_final_01") + schema = _schema_check(vtc, "vtc.schema.json") + st, _ = h.client.propose(vtc) + assert st == 201, st + + dlv = agents.make_delivery(vtc, h.seller, b"the delivered bytes", b"results") + dschema = _schema_check(dlv, "delivery.schema.json") + st, _ = h.client.deliver(dlv) + assert st == 202, st + + vd = agents.make_verdict(vtc, dlv, h.verifier, "PASS") + vschema = _schema_check(vd, "verdict.schema.json") + st, _ = h.client.verdict(vd) + assert st == 201, st + + st, att = h.client.attestation(vtc["id"]) + assert st == 200, att + ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, + "schema": {"contract": schema, "delivery": dschema, "verdict": vschema, + "attestation": _schema_check(att, "attestation.schema.json")}, + "amounts": att["amounts"], "ledger": c.pools.ledger} + + +def run_settled(h: Harness) -> dict: + vtc = h.fresh("vtc_settled_01") + st, _ = h.client.propose(vtc) + assert st == 201, st + dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad results") + st, _ = h.client.deliver(dlv) + assert st == 202, st + vd = agents.make_verdict(vtc, dlv, h.verifier, "FAIL") + st, _ = h.client.verdict(vd) + assert st == 201, st + st, att = h.client.attestation(vtc["id"]) + ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, + "amounts": att["amounts"], "ledger": c.pools.ledger, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "seller_received": pc.money(c.pools.paid_to_seller)} + + +def run_settled_price(h: Harness) -> dict: + """The same fraud, with restitution_basis "price" instead of "released". + + This is the contrast that matters. Under "released" the amount owed to the + Buyer is what was paid out before the Verdict, which under the default + on-verification release is nothing, so the Bond passes the Buyer entirely + and lands on the remainder. Under "price" the Buyer is restored from the + Bond up to the cap. Same protocol, same fraud, opposite outcome for the + defrauded party, chosen by one enum in the contract. + """ + vtc = h.fresh("vtc_settled_price", restitution_basis="price") + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad") + h.client.deliver(dlv) + h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + st, att = h.client.attestation(vtc["id"]) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "attestation_verifies": + agents.check_attestation(att, h.resolver, FACILITATOR)[0], + "attestation_reason": "ok", + "amounts": att["amounts"], "ledger": c.pools.ledger, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "from_bond": pc.money(c.pools.paid_to_buyer - pc.cents(vtc["price"]["amount"]))} + + +def run_abandoned(h: Harness) -> dict: + # A deadline already past: the Seller signed, took the bond obligation, and + # never delivered. Section 6 calls this ABANDONED. + vtc = h.fresh("vtc_abandoned_01", deadline="2026-01-01T00:00:00Z") + st, _ = h.client.propose(vtc) + assert st == 201, st + st, body = h.client.contract(vtc["id"]) # the GET is what notices the expiry + st, att = h.client.attestation(vtc["id"]) + ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, + "amounts": att["amounts"], "ledger": c.pools.ledger, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "bond_kept_by_seller": pc.money(c.pools.bond_returned)} + + +# -------------------------------------------------------------------------- +# Refusals. What a settlement service will not do. +# -------------------------------------------------------------------------- + +def run_refusals(h: Harness) -> None: + def record(name: str, status: int, body: dict) -> None: + REPORT["refusals"][name] = { + "status": status, + "type": body.get("type", "").rsplit("/", 1)[-1], + "section": body.get("section"), + "detail": body.get("detail", "")[:200], + } + + # Section 7.2, before funds lock. A ten percent bond against a declared + # detection rate of 0.90 needs 20.00, so 18.00 is refused. These are the + # draft's own worked figures and its own error body, Section 12.3. + vtc = h.fresh("vtc_refuse_bond", q_min=0.90) + record("bond_below_constraint", *h.client.propose(vtc)) + + # Section 9.1, derived not declared: the Seller signs its own Verdict. + vtc = h.fresh("vtc_refuse_selfverify") + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"w", b"r") + h.client.deliver(dlv) + record("verdict_signed_by_seller", + *h.client.verdict(agents.make_verdict(vtc, dlv, h.seller, "PASS"))) + + # Section 3: the Facilitator cannot verify a contract it settles. + fac_party = agents.Party(FACILITATOR, h.fac.key, h.client) + record("verdict_signed_by_facilitator", + *h.client.verdict(agents.make_verdict(vtc, dlv, fac_party, "PASS"))) + + # Section 12.4: no Verdict without a recorded Delivery. + vtc2 = h.fresh("vtc_refuse_nodelivery") + h.client.propose(vtc2) + orphan = agents.make_verdict(vtc2, dlv, h.verifier, "PASS") + record("verdict_without_delivery", *h.client.verdict(orphan)) + + # Section 12.2: the same body twice is the same resource, 200 not 409. + vtc3 = h.fresh("vtc_idempotent") + h.client.propose(vtc3) + record("resubmit_identical_contract", *h.client.propose(vtc3)) + + # Section 12.2: the same id with different bytes is 409. + altered = json.loads(json.dumps(vtc3)) + altered["liability"]["seller_bond"] = "19.00" + altered = agents.cosign({k: v for k, v in altered.items() if k != "signatures"}, + h.buyer, h.seller) + record("altered_contract_same_id", *h.client.propose(altered)) + + # Section 9.1 again, at proposal: buyer and seller the same party. + same = agents.draft_contract("vtc_refuse_same", BUYER, BUYER + "/", + FACILITATOR, VERIFIER) + record("buyer_equals_seller", + *h.client.propose(agents.cosign(same, h.buyer, h.buyer))) + + # Section 13.1: an algorithm off the allowlist. + tampered = h.fresh("vtc_refuse_alg") + prot = pc.b64u(pc.jcs({"alg": "none", "kid": h.buyer.key.kid, "typ": "x"})) + tampered["signatures"][0]["protected"] = prot + record("algorithm_none", *h.client.propose(tampered)) + + +# -------------------------------------------------------------------------- +# Microbenchmarks +# -------------------------------------------------------------------------- + +def bench(fn, n: int = 2000) -> dict: + # Warm up, then take the median of per-call microseconds. + for _ in range(50): + fn() + samples = [] + for _ in range(7): + t0 = time.perf_counter() + for _ in range(n): + fn() + samples.append((time.perf_counter() - t0) / n * 1e6) + return {"median_us": round(statistics.median(samples), 2), + "min_us": round(min(samples), 2), "calls": n} + + +def run_micro(h: Harness) -> None: + vtc = h.fresh("vtc_bench") + dlv = agents.make_delivery(vtc, h.seller, b"w" * 4096, b"r" * 1024) + key = h.buyer.key + + REPORT["micro"]["canonicalize_contract"] = bench(lambda: pc.jcs(vtc)) + REPORT["micro"]["digest_contract"] = bench( + lambda: pc.digest_over(pc.hashable(vtc))) + REPORT["micro"]["sign_contract_ed25519"] = bench( + lambda: pc.sign(vtc, key, agents.MEDIA_CONTRACT), n=500) + entry = pc.sign(vtc, key, agents.MEDIA_CONTRACT) + REPORT["micro"]["verify_contract_ed25519"] = bench( + lambda: pc.verify_entry(vtc, entry, h.resolver), n=500) + REPORT["micro"]["normalize_identifier"] = bench(lambda: pc.norm(SELLER)) + REPORT["micro"]["assurance_constraint"] = bench( + lambda: pc.assurance_holds(180.0, 18.0, 0.9091, 0.0)) + + for n in (2, 8, 64): + leaves = [pc.jcs({"child": i}) for i in range(n)] + REPORT["micro"][f"merkle_root_{n}_children"] = bench( + lambda leaves=leaves: pc.mth(leaves), n=200 if n > 8 else 1000) + + REPORT["micro"]["canonical_bytes"] = { + "contract": len(pc.jcs(pc.hashable(vtc))), + "delivery": len(pc.jcs(pc.hashable(dlv))), + } + + # T0-reexec, the cheapest verification tier: the acceptance harness the + # contract commits to by hash. This is the cost the profile imposes, and it + # dominates every protocol operation above by orders of magnitude, which is + # the point worth making about where verification cost actually lives. + harness = ROOT / "examples" / "acceptance-harness" / "test_acceptance.py" + if harness.exists(): + t0 = time.perf_counter() + proc = subprocess.run([sys.executable, str(harness)], + capture_output=True, cwd=str(harness.parent)) + REPORT["micro"]["t0_reexec_acceptance_harness"] = { + "wall_ms": round((time.perf_counter() - t0) * 1000, 2), + "exit": proc.returncode, + } + + +# -------------------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--port", type=int, default=8412) + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + REPORT["environment"] = { + "python": platform.python_version(), + "platform": platform.platform(), + "processor": _cpu_name(), + "signature_algorithm": "Ed25519 (EdDSA, RFC 8037)", + "note": "single host, loopback HTTP, in-memory store, no payment rail", + } + + h = Harness(args.port) + try: + scenario(h, "FINAL", lambda: run_final(h)) + scenario(h, "SETTLED", lambda: run_settled(h)) + scenario(h, "ABANDONED", lambda: run_abandoned(h)) + scenario(h, "SETTLED_basis_price", lambda: run_settled_price(h)) + run_refusals(h) + run_micro(h) + finally: + h.stop() + + if args.json: + print(json.dumps(REPORT, indent=1)) + return + + e = REPORT["environment"] + print(f"reference pair on {e['processor']}, Python {e['python']}, {e['signature_algorithm']}") + print(f"{e['note']}\n") + + print("TERMINAL STATES") + for name, s in REPORT["scenarios"].items(): + print(f" {name:<10} {s['messages']} messages, " + f"{s['request_bytes']}B out / {s['response_bytes']}B back, " + f"{s['wall_ms']}ms, attestation verifies: {s['attestation_verifies']}") + print(f" amounts {s['amounts']}") + print() + + print("REFUSALS") + for name, r in REPORT["refusals"].items(): + print(f" {name:<32} {r['status']} {r['type']:<36} {r['section']}") + print() + + print("MICROBENCHMARKS, median microseconds per call") + for name, m in REPORT["micro"].items(): + if isinstance(m, dict) and "median_us" in m: + print(f" {name:<34} {m['median_us']:>10.2f} us") + cb = REPORT["micro"].get("canonical_bytes", {}) + if cb: + print(f" canonical bytes contract {cb['contract']}, " + f"delivery {cb['delivery']}") + t0 = REPORT["micro"].get("t0_reexec_acceptance_harness") + if t0: + print(f" T0-reexec acceptance harness {t0['wall_ms']:>10.2f} ms " + f"(exit {t0['exit']})") + + +if __name__ == "__main__": + main() diff --git a/tools/pactcore.py b/tools/pactcore.py new file mode 100644 index 0000000..ffe9f22 --- /dev/null +++ b/tools/pactcore.py @@ -0,0 +1,427 @@ +"""Shared PACT primitives: canonicalization, digests, JWS, and the assurance constraint. + +This module is the part of the reference implementation that both the +Facilitator (facilitator.py) and the party agents (agents.py) need. It is +deliberately separate from tools/validate.py, which stays a self-contained +conformance checker over the committed examples and must keep passing on a +machine with nothing but jsonschema installed. + +Two things here are worth reading before trusting a number produced with it. + +The canonicalizer is the same restricted RFC 8785 implementation validate.py +uses. It is correct for the value types PACT objects carry and is not a +conforming general JCS implementation; what it does get right, and what the +Section 13.3 vectors pin, is the UTF-16 code unit key order of RFC 8785 +section 3.2.3, which json.dumps(sort_keys=True) does not implement. + +The signatures are real. Section 13.1 fixes the JWS Signing Input as +ASCII(BASE64URL(UTF8(protected)) || "." || BASE64URL(JCS(object))) over the +object with its signing member removed, and Section 6 computes vtc_hash over +the contract *including* its signatures member. Those two facts are why a +contract digest proves who agreed rather than merely what was written, and +why the signing input and the digest are computed over different bytes. Both +are implemented here and exercised by the measurement harness. + +The committed examples under examples/ keep their placeholder signature +values on purpose. The published Internet-Draft prints their digests in +Section 14 and cannot be corrected, so re-signing them would silently +desynchronise the repository from the document. Everything in this module +mints fresh keys and fresh contracts at run time instead. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import unicodedata +from dataclasses import dataclass, field +from typing import Any, Callable + +# Ed25519 and P-256 come from `cryptography`. It is an optional dependency: +# validate.py's sixty-six checks do not need it, and this module is only +# imported by the Facilitator and the agents. +try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, + ) + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, + encode_dss_signature, + ) + from cryptography.exceptions import InvalidSignature + + HAVE_CRYPTO = True +except ImportError: # pragma: no cover + HAVE_CRYPTO = False + + class InvalidSignature(Exception): + pass + + +# -------------------------------------------------------------------------- +# RFC 8785 canonicalization and digests +# -------------------------------------------------------------------------- + +def _utf16_key_order(obj: Any) -> Any: + """Reorder object keys by UTF-16 code unit, per RFC 8785 section 3.2.3. + + Comparing UTF-16 big-endian encodings bytewise is equivalent to comparing + sequences of UTF-16 code units. json.dumps preserves insertion order, so + building the dict in the right order is enough; sort_keys must NOT also + be set, since that re-sorts by code point and the two orders diverge + above the Basic Multilingual Plane. + """ + if isinstance(obj, dict): + return {k: _utf16_key_order(obj[k]) + for k in sorted(obj, key=lambda s: s.encode("utf-16-be"))} + if isinstance(obj, list): + return [_utf16_key_order(v) for v in obj] + return obj + + +def jcs(obj: Any) -> bytes: + """Restricted RFC 8785 canonical serialization. See the module docstring.""" + return json.dumps(_utf16_key_order(obj), separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + + +def h(b: bytes) -> str: + return "sha256:" + hashlib.sha256(b).hexdigest() + + +def digest_over(obj: Any) -> str: + """Digest over the whole object as it stands, signatures included. + + This is the Section 6 construction. Use it for vtc_hash, and never for a + signing input. + """ + return h(jcs(obj)) + + +# PACT objects carry their signatures two ways, and both are in the schemas. +# The contract and the Work Attestation take an array, because more than one +# party signs them. Delivery, Verdict and Challenge take a single object, +# because exactly one party does. +SIGNING_MEMBERS = ("signatures", "signature") + +# The Facilitator adds `state` to a response body. Section 12 says it is not +# part of the signed object and MUST NOT be included when the object is +# canonicalized or hashed, so it is stripped everywhere alongside signatures. +UNSIGNED_MEMBERS = SIGNING_MEMBERS + ("state",) + + +def signable(obj: dict) -> dict: + return {k: v for k, v in obj.items() if k not in UNSIGNED_MEMBERS} + + +def hashable(obj: dict) -> dict: + """The object as it is committed to: signatures kept, transport state dropped.""" + return {k: v for k, v in obj.items() if k != "state"} + + +def signature_entries(obj: dict) -> list[dict]: + """Every signature entry on an object, whichever member carries them.""" + if "signatures" in obj: + return list(obj["signatures"]) + if "signature" in obj: + return [obj["signature"]] + return [] + + +def attach(obj: dict, entry: dict, array: bool) -> dict: + obj["signatures" if array else "signature"] = [entry] if array else entry + return obj + + +# -------------------------------------------------------------------------- +# Identifier normalization, Section 9.1 +# -------------------------------------------------------------------------- + +def norm(identifier: str) -> str: + """Normalize a party identifier before comparing two of them. + + Section 9.1 requires that independence be *derived* by comparing party + identifiers, not declared in a field. Comparison without normalization is + the hole: two identifiers differing only by case, a trailing separator or + Unicode form would compare unequal and a Seller could verify its own work. + Conformance vector V-07 pins this. + """ + s = unicodedata.normalize("NFC", identifier).strip().casefold() + while s.endswith(("/", "#", ":")): + s = s[:-1] + return s + + +def same_party(a: str, b: str) -> bool: + return norm(a) == norm(b) + + +# -------------------------------------------------------------------------- +# The assurance constraint, Section 7.2 +# -------------------------------------------------------------------------- + +def required_bond(price: float, q: float, released: float = 0.0) -> float: + """B >= P(1-q)/q + E. + + E, the amount already paid out before a Verdict is recorded, is the only + term this specification contributes; the rest is the classical deterrence + bound (Polinsky and Shavell; Belenkiy et al. Theorem 1; Mamageishvili and + Felten for rollup validators). Optimistic release both pays a defecting + Seller and puts that payment beyond recovery, so the required Bond rises + with it one for one. + """ + if q <= 0: + raise ValueError("q must be greater than zero") + return price * (1.0 - q) / q + released + + +def assurance_holds(price: float, bond: float, q_min: float, + released: float = 0.0) -> bool: + # Compare in cents to keep the decimal figures of a contract exact. + return round(bond * 100) >= round(required_bond(price, q_min, released) * 100) + + +# -------------------------------------------------------------------------- +# JWS General JSON Serialization with a detached payload, Section 13.1 +# -------------------------------------------------------------------------- + +ALLOWED_ALGS = ("EdDSA", "ES256", "ES384") + + +def b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=") + + +def b64u_decode(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def signing_input(protected: dict, obj: dict) -> bytes: + """ASCII(BASE64URL(UTF8(protected)) || "." || BASE64URL(JCS(object))). + + Exactly RFC 7515 section 5.1, over the object with its signing member + removed. The payload is never transmitted; a verifier rebuilds it from + the object it holds. + """ + return (b64u(jcs(protected)) + "." + b64u(jcs(signable(obj)))).encode("ascii") + + +@dataclass +class Key: + """A party key. `kid` is the URI a verifier resolves, per Section 13.1.1.""" + kid: str + alg: str + private: Any = None + public: Any = None + + @classmethod + def generate(cls, kid: str, alg: str = "EdDSA") -> "Key": + if not HAVE_CRYPTO: + raise RuntimeError( + "the `cryptography` package is required to mint keys; " + "install it with `pip install cryptography`") + if alg == "EdDSA": + sk = Ed25519PrivateKey.generate() + elif alg == "ES256": + sk = ec.generate_private_key(ec.SECP256R1()) + elif alg == "ES384": + sk = ec.generate_private_key(ec.SECP384R1()) + else: + raise ValueError(f"unsupported alg {alg}") + return cls(kid=kid, alg=alg, private=sk, public=sk.public_key()) + + def sign_bytes(self, data: bytes) -> bytes: + if self.alg == "EdDSA": + return self.private.sign(data) + curve_hash = hashes.SHA256() if self.alg == "ES256" else hashes.SHA384() + der = self.private.sign(data, ec.ECDSA(curve_hash)) + r, s = decode_dss_signature(der) + size = 32 if self.alg == "ES256" else 48 + return r.to_bytes(size, "big") + s.to_bytes(size, "big") + + def verify_bytes(self, sig: bytes, data: bytes) -> None: + if self.alg == "EdDSA": + self.public.verify(sig, data) + return + size = 32 if self.alg == "ES256" else 48 + if len(sig) != size * 2: + raise InvalidSignature("bad JWS ECDSA signature length") + r = int.from_bytes(sig[:size], "big") + s = int.from_bytes(sig[size:], "big") + curve_hash = hashes.SHA256() if self.alg == "ES256" else hashes.SHA384() + self.public.verify(encode_dss_signature(r, s), data, ec.ECDSA(curve_hash)) + + +class KeyResolver: + """Maps a `kid` to a public key. + + Section 13.1.1 resolves a kid as a DID URL (DID Core, did:web) or as an + https URI naming a JWK Set (RFC 7517). Both are network lookups with + caching and revocation semantics that a reference implementation should + not fake. This resolver is an in-process registry with the same interface, + so the Facilitator's verification path is real and only the transport of + the public key is stubbed. What that costs a measurement is one network + round trip per unseen kid, which is stated wherever numbers are reported. + """ + + def __init__(self) -> None: + self._keys: dict[str, Key] = {} + + def register(self, key: Key) -> Key: + self._keys[key.kid] = key + return key + + def resolve(self, kid: str) -> Key | None: + return self._keys.get(kid) + + +def sign(obj: dict, key: Key, typ: str) -> dict: + """Return one entry for the object's `signatures` array. + + `typ` is the full media type, per RFC 8725 section 3.11: a bare "JWT" or + an omitted typ lets an attacker present a token minted for one purpose as + one minted for another. + """ + protected = {"alg": key.alg, "kid": key.kid, "typ": typ} + sig = key.sign_bytes(signing_input(protected, obj)) + return {"protected": b64u(jcs(protected)), "signature": b64u(sig)} + + +def verify_entry(obj: dict, entry: dict, resolver: KeyResolver) -> tuple[bool, str]: + """Verify one signature entry. Returns (ok, reason).""" + try: + protected = json.loads(b64u_decode(entry["protected"])) + except Exception: + return False, "protected header is not valid base64url JSON" + + for member in ("alg", "kid", "typ"): + if member not in protected: + return False, f"protected header is missing {member}" + if "kid" in entry: + # Section 13.1: a kid outside the signed header is attacker-controlled. + return False, "kid carried as a sibling of the protected header" + alg = protected["alg"] + if alg not in ALLOWED_ALGS: + # Rejecting `none` and everything off the allowlist is the whole point: + # absent one, the attacker selects the algorithm. + return False, f"algorithm {alg!r} is not allowed" + + key = resolver.resolve(protected["kid"]) + if key is None: + return False, f"cannot resolve kid {protected['kid']!r}" + if key.alg != alg: + return False, f"alg {alg!r} does not match the resolved key" + + try: + key.verify_bytes(b64u_decode(entry["signature"]), + signing_input(protected, obj)) + except Exception: + return False, "signature does not verify" + return True, "ok" + + +def signer_kids(obj: dict) -> list[str]: + out = [] + for entry in signature_entries(obj): + try: + out.append(json.loads(b64u_decode(entry["protected"]))["kid"]) + except Exception: + continue + return out + + +def kid_covers(kid: str, party: str) -> bool: + """True when `kid` is a key identifier belonging to `party`. + + A kid is a DID URL or a JWK Set URI, so the party identifier is a prefix + of it once both are normalized. Comparing raw strings here would reopen + exactly the hole Section 9.1 closes. + """ + return norm(kid).startswith(norm(party)) + + +def verify_object(obj: dict, resolver: KeyResolver, typ: str, + required_parties: list[str]) -> tuple[bool, str]: + """Verify every signature and check that each required party signed once.""" + entries = signature_entries(obj) + if not entries: + return False, "object carries no signatures" + + for entry in entries: + ok, why = verify_entry(obj, entry, resolver) + if not ok: + return False, why + + kids = signer_kids(obj) + for party in required_parties: + covering = [k for k in kids if kid_covers(k, party)] + if not covering: + return False, f"no signature from {party}" + if len(covering) > 1: + return False, f"more than one signature from {party}" + return True, "ok" + + +# -------------------------------------------------------------------------- +# Merkle tree, RFC 9162 section 2.1.1 +# -------------------------------------------------------------------------- + +def mth(items: list[bytes]) -> bytes: + """Merkle Tree Hash over `items`, exactly as RFC 9162 defines it. + + Leaves are hashed with a 0x00 prefix and internal nodes with 0x01, and an + n-item tree splits at k, the largest power of two strictly less than n. + That split is not the same as promoting an odd node, which is what the + -00 said and what an earlier revision of this repository implemented. + """ + if not items: + return hashlib.sha256(b"").digest() + if len(items) == 1: + return hashlib.sha256(b"\x00" + items[0]).digest() + k = 1 + while k * 2 < len(items): + k *= 2 + return hashlib.sha256(b"\x01" + mth(items[:k]) + mth(items[k:])).digest() + + +# -------------------------------------------------------------------------- +# Money. Contracts carry decimal strings; arithmetic happens in cents. +# -------------------------------------------------------------------------- + +def cents(amount: str | float) -> int: + return round(float(amount) * 100) + + +def money(c: int) -> str: + return f"{c / 100:.2f}" + + +@dataclass +class Pools: + """The three pools of Section 7.1, in cents. + + Keeping the Verification Fund separate from the Bond is not tidiness. Under + the -00 a Challenger was reimbursed from the slashed Bond, so reimbursement + was capped by the Bond, and for any re-execution profile the cost of + producing a fraud proof approximates the cost of the work itself. That MUST + was unsatisfiable in the ordinary case. + """ + escrow: int = 0 + bond: int = 0 + fund: int = 0 + bond_initial: int = 0 + bond_returned: int = 0 + released: int = 0 # E in the constraint of Section 7.2 + paid_to_buyer: int = 0 + restituted: int = 0 + paid_to_seller: int = 0 + paid_to_challenger: int = 0 + remainder: int = 0 + ledger: list[str] = field(default_factory=list) + + def note(self, line: str) -> None: + self.ledger.append(line) From 5d9134bbf7a393cc5b1d51f01a42e64f91df6de4 Mon Sep 17 00:00:00 2001 From: Laxmikant Sharma Date: Wed, 9 Sep 2026 19:02:01 -0700 Subject: [PATCH 2/4] Fix six conformance defects and retract a false finding An adversarial review of the first version of this branch found that one of its two headline claims was false and that the implementation failed six normative MUSTs. All are fixed here, and the claim is retracted in tools/README.md rather than quietly deleted. The false claim was "a defrauded buyer still recovers nothing from the bond". Rank 1 of the Section 7.4 waterfall returns the whole escrow to the buyer before rank 3 is reached, so the buyer's loss is zero and a restitution of 0.00 is arithmetically correct. The transcript printed directly beneath the claim said so. The six defects, each reproduced before it was fixed: open_challenge applied the waterfall directly, so any party with a resolvable key could settle a contract on which no Verdict was ever recorded, destroy the seller's bond, and have an attestation of outcome "slashed" issued against it. Section 7.5 says a Challenge is evaluated by a party satisfying Section 9.1 whose finding is a Verdict, and that the Challenger's own assertion is not. Accepting a Challenge now moves the contract to DISPUTED and nothing else, and record_verdict accepts a Verdict in that state. record_verdict never checked the signer against parties.verifier, so any resolvable key could pass or fail any contract. Line 1852 makes that an unconditional MUST where the contract names one. verify_object took an expected media type and never compared it, so a signature minted over a Delivery was accepted on a Verdict. That is conformance vector V-05, which validate.py implements and passes in the same directory. kid_covers tested startswith, so did:web:acme.example.evil signed as did:web:acme.example. norm casefolded the whole identifier and never stripped the fragment. Section 9.1 folds only the scheme and, for did:web and https, the host, and removes the fragment and a trailing "/" or ".". submit_delivery accepted a Delivery with no evidence member. Line 832 requires rejecting it and applying Section 7.4 as though a FAIL Verdict had been recorded; both halves are normative and both now happen. measure.py now asserts money conservation per scenario, which immediately caught a seventh defect: the verification fund was zeroed on return without being credited to anyone, losing 0.50 in every run. The T0-reexec figure is removed. test_acceptance.py is a pytest module, so running it as a script executed no tests and exited 0 in silence; the number was pytest's import time, and the claim built on it that verification cost exceeds protocol cost by three orders of magnitude had no measurement behind it. Problem type URIs are now urn:pact:problem: rather than a github.io URL that does not resolve. Section 18.5 creates no registry. validate.py remains untouched and still passes its 66 checks. Co-Authored-By: Claude Fable 5.1 --- tools/README.md | 90 +++++++++++++++++++++---------------- tools/facilitator.py | 97 +++++++++++++++++++++++++++++++--------- tools/measure.py | 104 ++++++++++++++++++++++++++++++++++--------- tools/pactcore.py | 65 ++++++++++++++++++++------- 4 files changed, 260 insertions(+), 96 deletions(-) diff --git a/tools/README.md b/tools/README.md index 3fc93c0..4528a77 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,19 +1,19 @@ # tools -Four programs. The first checks the committed examples; the other three are a -working implementation of the protocol. +Five programs. The first checks the committed examples; the rest are a working +implementation of the protocol. | File | What it is | |---|---| | `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. | | `pactcore.py` | Canonicalization, digests, JWS signing and verification, identifier normalization, the assurance constraint, and the RFC 9162 Merkle tree. | -| `facilitator.py` | A reference Facilitator: the six operations of Section 12 over five paths, the Figure 2 state machine, the Section 7.4 waterfall, and RFC 9457 refusals that name the rule. | +| `facilitator.py` | A reference Facilitator: the six operations of Table 1 over five paths, the Figure 2 state machine, the Section 7.4 waterfall, and RFC 9457 refusals that name the rule. | | `agents.py` | Buyer, Seller, Verifier and Challenger clients. | -| `measure.py` | Drives three contracts to the three terminal states, exercises the refusal paths, and reports what it costs. | +| `measure.py` | Drives four contracts through the terminal states, exercises thirteen refusal paths, checks that money balances, and reports costs. | ``` pip install jsonschema referencing # validate.py -pip install cryptography pytest # everything else +pip install cryptography # everything else python3 tools/validate.py python3 tools/measure.py ``` @@ -25,10 +25,11 @@ still validates on a machine with nothing else installed. Section 15 of the draft records that no Facilitator, Buyer or Seller exchanging messages over the Section 12 endpoints was known to the author when -01 was -posted. This is that implementation, and it is one implementation. The -falsifiable experiment of Section 1.4 is *two independent* implementations -settling contracts through every terminal state, so this is the first half of -it and an invitation for the second. +posted. This is that implementation, and it is one implementation written by the +same person who wrote the specification, which is the weakest possible evidence +that the specification is implementable. The falsifiable experiment of Section +1.4 needs *two independent* implementations settling each other's contracts. +This is an invitation for the second, not a substitute for it. The signatures are real Ed25519. The contracts `measure.py` mints are fresh and so are the keys, deliberately: the committed examples under `examples/` keep @@ -42,18 +43,23 @@ No payment rail is touched. Section 1.2 puts the rail out of scope and three pools. What is real is the object flow, the state machine, the signature verification and the arithmetic. -## Two defects this implementation found +## Questions this raises for -02 -Both are in the specification, not in the code, and both are -02 items. +These are readings of the specification that an implementer has to resolve +before writing code, and the draft does not resolve them. They are not bugs in +this code, and none of them was discovered by running it: the arithmetic came +from an adversarial review of the text in early September, and building the +implementation confirmed it and added the second item below. -**The rule that is supposed to make silence expensive makes it free.** Section 6 -says "This is the rule that makes silence expensive: under the -00 the cheapest -attack was to deliver nothing verifiable and be paid anyway", and then requires -that on a missed deadline the Facilitator "slash the Bond to the extent of -`liability.restitution_basis`". The worked example sets `restitution_basis` to -`released`, and under the default `on-verification` release nothing is released -before a Verdict, so the extent is zero. Run it and the Seller signs, posts a -bond, delivers nothing, and gets the whole bond back: +**Non-delivery can carry no bond consequence, and the disposition of the bond is +then unspecified.** Section 6 requires that on a missed deadline the Facilitator +"slash the Bond to the extent of `liability.restitution_basis`". The worked +example sets that member to `released`, and under the default `on-verification` +release nothing is released before a Verdict, so the extent is zero. Separately, +Section 7.6 requires the Bond be returned "when the contract reaches FINAL or +SETTLED" and says nothing about ABANDONED, which is the third terminal state. So +the specification neither slashes the bond nor returns it. This implementation +returns it, which is a choice it had to make and not a rule it followed: ``` locked escrow 180.00, bond 18.00, fund 0.50 @@ -62,22 +68,30 @@ restitution basis 'released' gives 0.00 from bond returned bond 18.00 to seller ``` -**A defrauded buyer still recovers nothing from the bond.** Section 7.4 reorders -the waterfall to pay restitution before any bounty, and says of the -00 that -because the remainder went to a neutral sink "a defrauded Buyer recovered -nothing". Under `restitution_basis: released` the amount owed at rank 3 is again -zero, so the bond falls through to rank 5 and lands on the same sink: - -``` -rank 1: reversed unreleased escrow 180.00 to buyer -rank 3: restitution basis 'released' owed 0.00, paid 0.00 from bond -rank 5: remainder 18.00 directed to sink -``` - -Change one enum to `price` and the same fraud pays the buyer 18.00 out of the -bond. Same protocol, same fraud, opposite outcome for the injured party, decided -by a member whose default the document never argues for. - -The fix is a -02 question, not a code change: either the worked example should -use `price`, or `restitution_basis` needs a stated default and a rule that a -Facilitator refuses a combination that makes the remedy vacuous. +**The `restitution_basis` default is load-bearing and undefended.** Under +`released` the amount owed to the Buyer at rank 3 of the Section 7.4 waterfall +is whatever was paid out before the Verdict, which under the default release +mode is nothing. Under `price` the Buyer is restored from the Bond up to the +cap. The document never argues for one over the other, and the worked example +picks `released` without comment. Note carefully what this does and does not +mean: in the ordinary pre-release FAIL path the Buyer is made whole anyway, +because rank 1 returns the full unreleased escrow first, so a rank 3 payment of +zero is arithmetically correct rather than a failure. The member matters in the +paths where value has already moved, which is the Figure 6 overturned PASS. + +**The Challenge object carries no cost claim.** Rank 2 of the waterfall +reimburses "the successful Challenger's documented verification and submission +costs" from the Verification Fund, and the Challenge schema is closed and has no +member for those costs. A Facilitator has nothing in the object to reimburse +against. + +## A correction + +An earlier version of this file claimed, as a second defect, that "a defrauded +buyer still recovers nothing from the bond". That was wrong, and it was wrong in +the transcript printed directly beneath it: rank 1 returns the whole 180.00 +escrow to the Buyer before rank 3 is reached, so the Buyer's loss is zero and a +restitution payment of zero is correct. It is recorded here rather than quietly +deleted because the same misreading is easy for anyone else reading the +waterfall, and because the point of publishing a specification for demolition is +lost if the corrections are not published too. diff --git a/tools/facilitator.py b/tools/facilitator.py index 0e858ba..0d25b0c 100644 --- a/tools/facilitator.py +++ b/tools/facilitator.py @@ -61,7 +61,11 @@ protected header """ -PROBLEM_BASE = "https://pact-spec.github.io/problems/" +# Section 18.5: "This document creates no registry for its problem types." A +# URL that 404s is worse than an opaque identifier, so these are document-local +# URNs until a registry exists. RFC 9457 permits any URI and does not require +# it to dereference. +PROBLEM_BASE = "urn:pact:problem:" # Section 18.5 reserves these names but does not create a registry; the draft # asks IANA to create one on publication. Until then these are the document's @@ -78,6 +82,7 @@ "wrong-state": (409, "Section 12"), "object-conflict": (409, "Section 12.2"), "challenge-window-closed": (409, "Section 7.4"), + "evidence-nonconformant": (422, "Section 6"), "release-exceeds-bond": (422, "Section 7.1"), "unknown-contract": (404, "Section 12"), } @@ -259,6 +264,7 @@ def _return_bond(self, c: Contract) -> None: c.pools.bond = 0 if c.pools.fund: c.pools.note(f"returned verification fund {pc.money(c.pools.fund)}") + c.pools.fund_returned += c.pools.fund c.pools.fund = 0 # -- Submit Delivery, Section 12 -------------------------------------- @@ -281,6 +287,38 @@ def submit_delivery(self, dlv: dict) -> tuple[int, dict]: if not ok: raise Refuse("signature-invalid", why) + # Section 6: the Delivery is the thing being judged, and evidence + # must conform to the profile the contract declares. Without this + # the -00's cheapest attack, deliver nothing verifiable, is open + # again. validate.py's negative vector V-14 covers the same rule. + # Draft line 832: "A Facilitator MUST reject a Delivery whose + # evidence is absent or does not conform to the profile named in the + # VTC, and MUST apply Section 7.4 as though a FAIL Verdict had been + # recorded." Both halves are normative. Refusing without applying + # the waterfall would leave the contract sitting in FUNDED with the + # Buyer's escrow locked, which is the outcome the rule exists to + # prevent. This is the rule the draft calls the one that makes + # silence expensive. + evidence = dlv.get("evidence") + declared = c.vtc["verification"]["profile"] + reason = None + if not isinstance(evidence, dict): + reason = "the Delivery carries no evidence member" + elif evidence.get("profile") != declared: + reason = (f"evidence profile {evidence.get('profile')!r} does not " + f"match the contract's declared profile {declared!r}") + elif evidence.get("instrument_hash") != \ + c.vtc["verification"]["criteria_hash"]: + reason = ("the evidence does not commit to the instrument the " + "contract committed to") + if reason is not None: + c.pools.note(f"nonconformant Delivery: {reason}; applying " + f"Section 7.4 as though a FAIL Verdict were recorded") + self._apply_waterfall(c, challenger=c.buyer, costs=0) + raise Refuse("evidence-nonconformant", reason, + state=c.state, + remedy="Section 7.4 applied as though FAIL") + c.delivery = dlv c.state = "DELIVERED" body = dict(dlv) @@ -324,28 +362,47 @@ def record_verdict(self, verdict: dict) -> tuple[int, dict]: return 200, existing c = self._contract_for(verdict) - if c.state != "DELIVERED": - raise Refuse("wrong-state", f"contract {c.id} is {c.state}") + if c.state not in ("DELIVERED", "DISPUTED"): + raise Refuse("wrong-state", + f"contract {c.id} is {c.state}; a Verdict is accepted " + f"on DELIVERED, or on DISPUTED to resolve a challenge") kids = pc.signer_kids(verdict) if not kids: raise Refuse("signature-missing", "the Verdict carries no signature") - # Section 9.1, derived not declared. A field that says independent - # is satisfied by typing the word. + # Draft line 1852: "The Verifier is the party identified by the kid + # of the Verdict's signature. Where the contract names + # parties.verifier, the Verdict MUST be signed by that party; + # otherwise the Facilitator evaluates Section 9.1 against the + # signer." An earlier version checked only that the signer was not + # the Seller or the Facilitator, so any resolvable key could pass or + # fail any contract. + named = c.vtc["parties"].get("verifier") + if named: + if not any(pc.kid_covers(k, named) for k in kids): + raise Refuse("verifier-not-independent", + "the contract names a verifier and the Verdict " + "is not signed by that party", + signer=kids[0], required_verifier=named) for kid in kids: if pc.kid_covers(kid, c.seller): raise Refuse("verifier-not-independent", "the Verdict is signed by the contract's Seller", signer=kid, seller=c.seller) + if pc.kid_covers(kid, c.buyer): + raise Refuse("verifier-not-independent", + "the Verdict is signed by the contract's Buyer", + signer=kid, buyer=c.buyer) if pc.kid_covers(kid, self.identity): raise Refuse("facilitator-cannot-verify", "a Facilitator MUST NOT act as Verifier for a " "contract it settles", signer=kid, facilitator=self.identity) - ok, why = pc.verify_object(verdict, self.resolver, - "application/pact-verdict+json", []) + ok, why = pc.verify_object( + verdict, self.resolver, "application/pact-verdict+json", + [named] if named else []) if not ok: raise Refuse("signature-invalid", why) @@ -398,22 +455,19 @@ def open_challenge(self, ch: dict) -> tuple[int, dict]: if not ok: raise Refuse("signature-invalid", why) + # A Challenge is a fraud proof submitted for evaluation, not a + # finding. Section 7.5: "A Challenge that is accepted is evaluated + # by a party satisfying Section 9.1, whose finding is a Verdict; the + # Challenger's own assertion is not." An earlier version of this + # file applied the waterfall directly here, which let any party with + # a resolvable key destroy a Seller's bond with no Verdict ever + # recorded. Accepting a Challenge moves the contract to DISPUTED and + # nothing else. c.challenge = ch c.state = "DISPUTED" - - # The Challenge object carries no member for the documented costs - # that waterfall rank 2 must reimburse, and the schema is closed, - # so a Facilitator has nothing in the object to reimburse against. - # Implementing this surfaced the gap. Until a -02 adds a cost claim, - # the honest reading is that the Verification Fund is sized for one - # challenge under the contract's profile and is spent on one, so - # that is what this implementation does. Recorded in the ledger. - challenger = pc.signer_kids(ch)[0] if pc.signer_kids(ch) else c.buyer - costs = c.pools.fund - c.pools.note("rank 2 note: the Challenge object defines no documented " - "cost member, so the whole Verification Fund is treated " - "as the sized reimbursement. This is a -02 gap.") - self._apply_waterfall(c, challenger=challenger, costs=costs) + c.pools.note(f"challenge accepted from " + f"{pc.signer_kids(ch)[0] if pc.signer_kids(ch) else 'unknown'}, " + f"awaiting a Verdict from an independent evaluator") body = dict(ch) body["state"] = c.state self._remember("challenge", ch, body) @@ -475,6 +529,7 @@ def _apply_waterfall(self, c: Contract, challenger: str, costs: int) -> None: if p.fund: p.note(f"returned unspent verification fund {pc.money(p.fund)}") + p.fund_returned += p.fund p.fund = 0 c.state = "SETTLED" diff --git a/tools/measure.py b/tools/measure.py index c728f18..f6f90f4 100644 --- a/tools/measure.py +++ b/tools/measure.py @@ -94,6 +94,18 @@ def fresh(self, vid: str, **kw) -> dict: return agents.cosign(vtc, self.buyer, self.seller) +def conservation(c) -> dict: + """Every cent that went in must be accounted for at a terminal state.""" + p = c.pools + put_in = p.bond_initial + pc.cents(c.vtc["price"]["amount"]) + \ + pc.cents(c.vtc["liability"]["verification_fund"]) + accounted = (p.paid_to_buyer + p.paid_to_seller + p.paid_to_challenger + + p.remainder + p.bond_returned + p.fund_returned + + p.escrow + p.bond + p.fund) + return {"in": pc.money(put_in), "accounted": pc.money(accounted), + "balanced": put_in == accounted} + + def scenario(h: Harness, name: str, run) -> dict: before = len(h.client.wire) t0 = time.perf_counter() @@ -111,7 +123,9 @@ def scenario(h: Harness, name: str, run) -> dict: "req": w.request_bytes, "resp": w.response_bytes, "ms": round(w.seconds * 1000, 2)} for w in wire], } - out.update({k: v for k, v in detail.items() if k != "state"}) + out.update({k: v for k, v in detail.items() if k not in ("state", "contract")}) + if "contract" in detail: + out["money"] = conservation(detail["contract"]) REPORT["scenarios"][name] = out return out @@ -143,7 +157,7 @@ def run_final(h: Harness) -> dict: return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, "schema": {"contract": schema, "delivery": dschema, "verdict": vschema, "attestation": _schema_check(att, "attestation.schema.json")}, - "amounts": att["amounts"], "ledger": c.pools.ledger} + "amounts": att["amounts"], "ledger": c.pools.ledger, "contract": c} def run_settled(h: Harness) -> dict: @@ -162,7 +176,7 @@ def run_settled(h: Harness) -> dict: return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, "amounts": att["amounts"], "ledger": c.pools.ledger, "buyer_recovered": pc.money(c.pools.paid_to_buyer), - "seller_received": pc.money(c.pools.paid_to_seller)} + "seller_received": pc.money(c.pools.paid_to_seller), "contract": c} def run_settled_price(h: Harness) -> dict: @@ -187,7 +201,8 @@ def run_settled_price(h: Harness) -> dict: "attestation_reason": "ok", "amounts": att["amounts"], "ledger": c.pools.ledger, "buyer_recovered": pc.money(c.pools.paid_to_buyer), - "from_bond": pc.money(c.pools.paid_to_buyer - pc.cents(vtc["price"]["amount"]))} + "from_bond": pc.money(c.pools.paid_to_buyer - pc.cents(vtc["price"]["amount"])), + "contract": c} def run_abandoned(h: Harness) -> dict: @@ -203,7 +218,7 @@ def run_abandoned(h: Harness) -> dict: return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, "amounts": att["amounts"], "ledger": c.pools.ledger, "buyer_recovered": pc.money(c.pools.paid_to_buyer), - "bond_kept_by_seller": pc.money(c.pools.bond_returned)} + "bond_kept_by_seller": pc.money(c.pools.bond_returned), "contract": c} # -------------------------------------------------------------------------- @@ -268,6 +283,56 @@ def record(name: str, status: int, body: dict) -> None: tampered["signatures"][0]["protected"] = prot record("algorithm_none", *h.client.propose(tampered)) + # --- the cases the September review found this implementation failing --- + + # Draft line 1852: the contract names a verifier, so only that party judges. + stranger = agents.make_party("did:web:watchdog.example", h.resolver, h.client) + vtc4 = h.fresh("vtc_refuse_stranger") + h.client.propose(vtc4) + d4 = agents.make_delivery(vtc4, h.seller, b"w", b"r") + h.client.deliver(d4) + record("verdict_by_unnamed_party", + *h.client.verdict(agents.make_verdict(vtc4, d4, stranger, "PASS"))) + + # Section 7.5: a Challenge is evaluated by an independent party whose + # finding is a Verdict. Accepting one must NOT settle the contract. + st, _ = h.client.challenge(agents.make_challenge(vtc4, d4, stranger, ["rows"])) + c4 = h.fac.contracts["vtc_refuse_stranger"] + REPORT["refusals"]["challenge_alone_does_not_settle"] = { + "status": st, "type": f"state stays {c4.state}", + "section": "Section 7.5", + "detail": f"bond intact: {pc.money(c4.pools.bond)} of " + f"{pc.money(c4.pools.bond_initial)}, attestation issued: " + f"{c4.attestation is not None}", + } + + # Section 6: a Delivery must carry evidence conformant to the profile. + vtc5 = h.fresh("vtc_refuse_noevidence") + h.client.propose(vtc5) + d5 = agents.make_delivery(vtc5, h.seller, b"w", b"r") + del d5["evidence"] + d5 = h.seller.sign_into(d5, agents.MEDIA_DELIVERY) + record("delivery_without_evidence", *h.client.deliver(d5)) + + # RFC 8725 3.11 and vector V-05: typ carries the full media type. + vtc6 = h.fresh("vtc_refuse_typ") + h.client.propose(vtc6) + d6 = agents.make_delivery(vtc6, h.seller, b"w", b"r") + h.client.deliver(d6) + wrong = agents.make_verdict(vtc6, d6, h.verifier, "PASS") + wrong["signature"] = pc.sign({k: v for k, v in wrong.items() if k != "signature"}, + h.verifier.key, agents.MEDIA_DELIVERY) + record("verdict_signed_with_delivery_typ", *h.client.verdict(wrong)) + + # Section 9.1 normalization: a longer identifier is a different party. + evil = agents.make_party(SELLER + ".evil", h.resolver, h.client) + vtc7 = h.fresh("vtc_refuse_prefix") + h.client.propose(vtc7) + d7 = agents.make_delivery(vtc7, h.seller, b"w", b"r") + h.client.deliver(d7) + record("verdict_by_prefix_lookalike", + *h.client.verdict(agents.make_verdict(vtc7, d7, evil, "PASS"))) + # -------------------------------------------------------------------------- # Microbenchmarks @@ -314,19 +379,17 @@ def run_micro(h: Harness) -> None: "delivery": len(pc.jcs(pc.hashable(dlv))), } - # T0-reexec, the cheapest verification tier: the acceptance harness the - # contract commits to by hash. This is the cost the profile imposes, and it - # dominates every protocol operation above by orders of magnitude, which is - # the point worth making about where verification cost actually lives. - harness = ROOT / "examples" / "acceptance-harness" / "test_acceptance.py" - if harness.exists(): - t0 = time.perf_counter() - proc = subprocess.run([sys.executable, str(harness)], - capture_output=True, cwd=str(harness.parent)) - REPORT["micro"]["t0_reexec_acceptance_harness"] = { - "wall_ms": round((time.perf_counter() - t0) * 1000, 2), - "exit": proc.returncode, - } + # There is deliberately NO T0-reexec figure here. An earlier version timed + # examples/acceptance-harness/test_acceptance.py by running it as a plain + # script, which executes no tests at all: it is a pytest module, so it + # imports, defines its test functions, and exits 0 in silence. The number + # that produced was pytest's import time and nothing else, and the claim + # built on it, that verification cost exceeds protocol cost by three orders + # of magnitude, had no measurement behind it. Producing an honest figure + # needs the harness invoked through pytest with its documented arguments, + # and those currently fail because pytest_addoption sits in a test module + # rather than a conftest.py. Moving it changes the directory manifest and + # therefore criteria_hash, which is a -02 item. # -------------------------------------------------------------------------- @@ -385,10 +448,7 @@ def main() -> None: if cb: print(f" canonical bytes contract {cb['contract']}, " f"delivery {cb['delivery']}") - t0 = REPORT["micro"].get("t0_reexec_acceptance_harness") - if t0: - print(f" T0-reexec acceptance harness {t0['wall_ms']:>10.2f} ms " - f"(exit {t0['exit']})") + print("\n no T0-reexec figure is reported; see the comment in run_micro") if __name__ == "__main__": diff --git a/tools/pactcore.py b/tools/pactcore.py index ffe9f22..908def2 100644 --- a/tools/pactcore.py +++ b/tools/pactcore.py @@ -142,16 +142,38 @@ def attach(obj: dict, entry: dict, array: bool) -> dict: # -------------------------------------------------------------------------- def norm(identifier: str) -> str: - """Normalize a party identifier before comparing two of them. - - Section 9.1 requires that independence be *derived* by comparing party - identifiers, not declared in a field. Comparison without normalization is - the hole: two identifiers differing only by case, a trailing separator or - Unicode form would compare unequal and a Seller could verify its own work. - Conformance vector V-07 pins this. + """Normalize a party identifier per Section 9.1, exactly as written. + + "strip leading and trailing whitespace; lower-case the scheme and, for + did:web and https identifiers, the host; remove any fragment (a "#" and + everything after it) and any trailing "/" or ".". Percent-encoding MUST NOT + be decoded, since an open-ended decoder is its own attack surface." + + Note what this does NOT do. It does not case-fold the whole identifier: the + path of a did:web is case sensitive, and folding it would merge two + distinct parties. An earlier version of this function folded everything and + never stripped the fragment, which is both too permissive and too strict in + different places. """ - s = unicodedata.normalize("NFC", identifier).strip().casefold() - while s.endswith(("/", "#", ":")): + s = unicodedata.normalize("NFC", identifier).strip() + s = s.split("#", 1)[0] + + if ":" in s: + scheme, rest = s.split(":", 1) + scheme = scheme.lower() + if scheme == "did": + parts = rest.split(":") + if parts: + parts[0] = parts[0].lower() # the DID method + if parts[0] == "web" and len(parts) > 1: + parts[1] = parts[1].lower() # the host + rest = ":".join(parts) + elif scheme in ("http", "https") and rest.startswith("//"): + host, sep, tail = rest[2:].partition("/") + rest = "//" + host.lower() + sep + tail + s = scheme + ":" + rest + + while s.endswith(("/", ".")): s = s[:-1] return s @@ -291,7 +313,8 @@ def sign(obj: dict, key: Key, typ: str) -> dict: return {"protected": b64u(jcs(protected)), "signature": b64u(sig)} -def verify_entry(obj: dict, entry: dict, resolver: KeyResolver) -> tuple[bool, str]: +def verify_entry(obj: dict, entry: dict, resolver: KeyResolver, + expect_typ: str | None = None) -> tuple[bool, str]: """Verify one signature entry. Returns (ok, reason).""" try: protected = json.loads(b64u_decode(entry["protected"])) @@ -304,6 +327,14 @@ def verify_entry(obj: dict, entry: dict, resolver: KeyResolver) -> tuple[bool, s if "kid" in entry: # Section 13.1: a kid outside the signed header is attacker-controlled. return False, "kid carried as a sibling of the protected header" + # RFC 8725 section 3.11 and conformance vector V-05: typ carries the full + # media type so a signature minted over one object cannot be presented as + # one minted over another. This was previously accepted as an argument and + # never compared, which made V-05 pass in validate.py and fail over the wire. + if expect_typ is not None and protected.get("typ") != expect_typ: + return False, (f"typ {protected.get('typ')!r} does not match the expected " + f"{expect_typ!r}") + alg = protected["alg"] if alg not in ALLOWED_ALGS: # Rejecting `none` and everything off the allowlist is the whole point: @@ -337,11 +368,14 @@ def signer_kids(obj: dict) -> list[str]: def kid_covers(kid: str, party: str) -> bool: """True when `kid` is a key identifier belonging to `party`. - A kid is a DID URL or a JWK Set URI, so the party identifier is a prefix - of it once both are normalized. Comparing raw strings here would reopen - exactly the hole Section 9.1 closes. + Section 9.1 normalization strips the fragment, so a kid of + did:web:seller.example#key-1 normalizes to the party identifier itself and + this is an equality test. It used to be a prefix test, which is a hole: + did:web:acme.example.evil starts with did:web:acme.example and would have + signed as its neighbour. Line 1965 of the draft requires equality of the + normalized identifier, not containment. """ - return norm(kid).startswith(norm(party)) + return norm(kid) == norm(party) def verify_object(obj: dict, resolver: KeyResolver, typ: str, @@ -352,7 +386,7 @@ def verify_object(obj: dict, resolver: KeyResolver, typ: str, return False, "object carries no signatures" for entry in entries: - ok, why = verify_entry(obj, entry, resolver) + ok, why = verify_entry(obj, entry, resolver, expect_typ=typ) if not ok: return False, why @@ -415,6 +449,7 @@ class Pools: fund: int = 0 bond_initial: int = 0 bond_returned: int = 0 + fund_returned: int = 0 released: int = 0 # E in the constraint of Section 7.2 paid_to_buyer: int = 0 restituted: int = 0 From 66d6a60b0f8441e762f2c8615a0b1143e39d127b Mon Sep 17 00:00:00 2001 From: Laxmikant Sharma Date: Sat, 12 Sep 2026 02:44:54 -0700 Subject: [PATCH 3/4] Open the challenge window, validate every object, refuse what is not implemented A second adversarial pass found the first version wrong in the one place the draft makes mandatory: under on-verification a PASS returned the Bond and reached FINAL in the same call, so no challenge window ever opened and the Figure 6 overturned-PASS path was unreachable. Now a PASS releases the price and opens the window on the Facilitator's clock (injectable; the harness advances it); the Bond and fund stay locked until the window closes; a Challenge is accepted only inside it, with a proof conformant to the profile, and settles nothing itself; a Verdict on a Challenge supersedes the earlier one and both are recorded; an upheld Challenge runs the waterfall with the released amount, and the attestation reads settled 180.00, restituted 18.00, slashed 18.00, which is what Section 11's Figure 12 should carry. Also fixed: task.deadline parsed in local time via mktime (now RFC 3339, UTC); Verdict and Challenge commitments bypassable by omitting delivery_hash or adding vtc_hash (every posted object now validates against its published schema, and the binding is by object kind); a bounty paid to a Challenger that did not exist; liability.cap never enforced; release modes, settlement bindings, assurance modes and profiles the capability document does not advertise accepted and stranded (refused at Propose with a named rule); a contract naming another Facilitator accepted (16.7); a named verifier who is the buyer or seller accepted; liability.parent silently ignored (subcontracts are refused as parent-unresolvable, not faked); the capability document unsigned and failing its own schema; the JWS signing input rebuilt from the parsed protected header rather than the transmitted bytes; the assurance constraint rounded to cents before comparison (now exact Decimal); a third party's signature accepted on a co-signed contract; Verdict instrument_hash and profile never compared to the contract; a Challenger judging its own Challenge; idempotent re-POSTs returning a stale snapshot; problem types in a private URN namespace (now the draft's https prefix and Table 9 statuses); Python exception text returned as a 409. measure.py now drives FINAL, SETTLED, ABANDONED and the overturned PASS, checks the capability document's schema and signature, asserts money conservation, and exercises 24 refusals each on the rule it is named for, plus 2 acceptances reported as acceptances. README lists the six choices the draft left open (C1 to C6) and what is not implemented. validate.py untouched, 66 checks pass. Co-Authored-By: Claude Opus 5 --- tools/README.md | 160 +++--- tools/agents.py | 10 +- tools/facilitator.py | 1171 +++++++++++++++++++++++++++--------------- tools/measure.py | 545 +++++++++++++------- tools/pactcore.py | 61 ++- 5 files changed, 1259 insertions(+), 688 deletions(-) diff --git a/tools/README.md b/tools/README.md index 4528a77..90ded32 100644 --- a/tools/README.md +++ b/tools/README.md @@ -5,21 +5,23 @@ implementation of the protocol. | File | What it is | |---|---| -| `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. | -| `pactcore.py` | Canonicalization, digests, JWS signing and verification, identifier normalization, the assurance constraint, and the RFC 9162 Merkle tree. | -| `facilitator.py` | A reference Facilitator: the six operations of Table 1 over five paths, the Figure 2 state machine, the Section 7.4 waterfall, and RFC 9457 refusals that name the rule. | +| `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. No check is cryptographic. | +| `pactcore.py` | Canonicalization, digests, JWS signing and verification over the transmitted protected header, identifier normalization, the assurance constraint in exact decimal arithmetic, and the RFC 9162 Merkle tree. | +| `facilitator.py` | A reference Facilitator: the six operations of Table 1 over five paths, the Figure 2 state machine including the challenge window and the Figure 6 overturned-PASS path, the Section 7.4 waterfall, schema validation of every posted object, a signed capability document, and RFC 9457 refusals in the draft's own namespace that name the rule. `--rules` prints what it enforces and what it chose. | | `agents.py` | Buyer, Seller, Verifier and Challenger clients. | -| `measure.py` | Drives four contracts through the terminal states, exercises thirteen refusal paths, checks that money balances, and reports costs. | +| `measure.py` | Drives five contracts through the terminal states on a clock the harness advances, exercises 24 refusals and 2 acceptances each on the rule it is named for, asserts that money balances, checks every minted object and the capability document against the schemas, and reports costs. | ``` -pip install jsonschema referencing # validate.py -pip install cryptography # everything else +pip install jsonschema referencing cryptography python3 tools/validate.py python3 tools/measure.py +python3 tools/facilitator.py --rules ``` -`cryptography` is optional and `validate.py` does not need it, so a checkout -still validates on a machine with nothing else installed. +`validate.py` needs only the first two packages, so a checkout still validates +on a machine with nothing else installed. The Facilitator refuses to start +without `jsonschema`, because Section 12.1 makes schema conformance a MUST at +Propose and a Facilitator that skips it is not one. ## What this does and does not answer @@ -43,55 +45,93 @@ No payment rail is touched. Section 1.2 puts the rail out of scope and three pools. What is real is the object flow, the state machine, the signature verification and the arithmetic. -## Questions this raises for -02 - -These are readings of the specification that an implementer has to resolve -before writing code, and the draft does not resolve them. They are not bugs in -this code, and none of them was discovered by running it: the arithmetic came -from an adversarial review of the text in early September, and building the -implementation confirmed it and added the second item below. - -**Non-delivery can carry no bond consequence, and the disposition of the bond is -then unspecified.** Section 6 requires that on a missed deadline the Facilitator -"slash the Bond to the extent of `liability.restitution_basis`". The worked -example sets that member to `released`, and under the default `on-verification` -release nothing is released before a Verdict, so the extent is zero. Separately, -Section 7.6 requires the Bond be returned "when the contract reaches FINAL or -SETTLED" and says nothing about ABANDONED, which is the third terminal state. So -the specification neither slashes the bond nor returns it. This implementation -returns it, which is a choice it had to make and not a rule it followed: - -``` -locked escrow 180.00, bond 18.00, fund 0.50 -deadline passed with no Delivery, returned escrow 180.00 to buyer -restitution basis 'released' gives 0.00 from bond -returned bond 18.00 to seller -``` - -**The `restitution_basis` default is load-bearing and undefended.** Under -`released` the amount owed to the Buyer at rank 3 of the Section 7.4 waterfall -is whatever was paid out before the Verdict, which under the default release -mode is nothing. Under `price` the Buyer is restored from the Bond up to the -cap. The document never argues for one over the other, and the worked example -picks `released` without comment. Note carefully what this does and does not -mean: in the ordinary pre-release FAIL path the Buyer is made whole anyway, -because rank 1 returns the full unreleased escrow first, so a rank 3 payment of -zero is arithmetically correct rather than a failure. The member matters in the -paths where value has already moved, which is the Figure 6 overturned PASS. - -**The Challenge object carries no cost claim.** Rank 2 of the waterfall -reimburses "the successful Challenger's documented verification and submission -costs" from the Verification Fund, and the Challenge schema is closed and has no -member for those costs. A Facilitator has nothing in the object to reimburse -against. - -## A correction - -An earlier version of this file claimed, as a second defect, that "a defrauded -buyer still recovers nothing from the bond". That was wrong, and it was wrong in -the transcript printed directly beneath it: rank 1 returns the whole 180.00 -escrow to the Buyer before rank 3 is reached, so the Buyer's loss is zero and a -restitution payment of zero is correct. It is recorded here rather than quietly -deleted because the same misreading is easy for anyone else reading the -waterfall, and because the point of publishing a specification for demolition is -lost if the corrections are not published too. +## Not implemented, and refused rather than faked + +A contract that needs any of these is refused at Propose with a problem body +saying so, rather than accepted and then stranded or silently mishandled: +subcontracts (Section 10; `liability.parent` is refused as `parent-unresolvable`), +release modes other than on-verification, assurance modes other than certain, +verification profiles other than acceptance, challenge deposits, settlement +bindings other than the one the capability document advertises, and amounts +finer than a cent. Key resolution is an in-process registry with the Section +13.1.1 interface; the network lookup is the part that is stubbed. + +## Choices the draft left to the implementer + +Each of these is a place where the -01 text is silent or says two things. +`facilitator.py --rules` prints the same list. They are choices, not rules, and +a second implementation is free to choose differently, which is exactly the +kind of disagreement the experiment exists to surface. + +1. **The Bond on ABANDONED.** Section 6 slashes it "to the extent of + `restitution_basis`", which under `released` with nothing released is zero. + Section 7.6 returns the Bond on FINAL or SETTLED and says nothing about the + third terminal state. This implementation returns it. Measured: a Seller that + signs, posts 18.00, and never delivers gets the whole 18.00 back. +2. **Rank 3 restores the Buyer's loss, net of rank 1.** Read literally, basis + `price` would pay the Bond on top of a reversed escrow in the pre-release + failure, a windfall the draft's own `remainder_to` rule exists to prevent. + Under the net reading the two basis values differ only when release was + partial; under on-verification they never differ. +3. **The bounty.** The draft requires it to be non-exclusive and forbids capping + it at a fraction "chosen for tidiness", and does not fix it. This + implementation pays the whole remaining Bond after rank 3, split equally among + successful Challengers. With K independent discoverers a full bounty each is + not fundable from one Bond, which the draft's text assumes it is. +4. **Rank 2 pays 0.00.** The Challenge object has no member for the documented + costs rank 2 reimburses, and its schema is closed. +5. **A Challenge with no Verdict inside `max_dispute_seconds` lapses**; the + earlier Verdict stands and the window is not extended. The draft declares the + bound and never applies it. +6. **PROPOSED is not observable.** With no rail the pools are debited in memory + when a co-signed contract is accepted, so the 201 reports FUNDED. + +## Measured on 12 September 2026 + +Intel Core i9-9880H at 2.30 GHz, Python 3.12.11, Ed25519, single host, loopback +HTTP, in-memory store, no payment rail, the Facilitator's clock advanced by the +harness. The same code has produced per-call figures two to three times apart +across sessions on the same laptop; the order of magnitude is the result. + +| Path | Exchanges | Request / response bytes | Attestation amounts (settled / restituted / slashed) | +|---|---|---|---| +| FINAL: PASS, window closes | 4 | 3,065 / 4,011 | 180.00 / 0.00 / 0.00 | +| SETTLED: verifier FAIL | 4 | 3,071 / 4,014 | 0.00 / 0.00 / 18.00 | +| ABANDONED: no Delivery | 3 | 1,469 / 3,780 | 0.00 / 0.00 / 0.00 | +| SETTLED: PASS overturned by a Challenge | 6 | 4,458 / 5,446 | 180.00 / 18.00 / 18.00 | + +Each lifecycle completes in 25 to 45 ms, most of it schema validation of the +posted objects. Per call, medians: canonicalize a contract 51 us; canonicalize +and digest 60 us; sign a contract including canonicalization 128 us; verify a +contract signature end to end 198 us, of which the Ed25519 primitive over 1.5 KB +is 123 us; normalize an identifier 1.3 us; the assurance constraint in exact +decimal 2.1 us; an RFC 9162 root over 2, 8 and 64 leaves 4, 21 and 179 us. + +The overturned-PASS row is the one the restitution basis does any work in, and +its amounts are what Section 11's worked attestation should carry: the draft's +example has restituted 18.00 with settled 0.00, which fits neither path. + +There is no verification-cost figure. The example instrument is a pytest module +whose runtime says nothing about real work, and an earlier version of this file +reported a number for it that was pytest's import time. + +## Corrections + +This file has been wrong twice, and both are recorded here rather than deleted, +because the point of publishing a specification for demolition is lost if the +corrections are not published too. + +An earlier version claimed, as a defect, that "a defrauded buyer still recovers +nothing from the bond". That was wrong, and it was wrong in the transcript +printed directly beneath it: rank 1 returns the whole escrow to the Buyer before +rank 3 is reached, so the Buyer's loss is zero and a restitution payment of zero +is correct. + +An earlier version of `facilitator.py` returned the Bond and reached FINAL in the +same call that recorded a PASS, so no challenge window ever opened and the +Figure 6 path was unreachable in the only release mode the draft requires. A +second adversarial review on 11 September found that, along with a deadline +parsed in local time, Verdict and Challenge commitments that could be bypassed +by omitting a member, a bounty paid to a Challenger that did not exist, an +unsigned capability document, and no schema validation at Propose. All are fixed +and the numbers above are from the corrected code. diff --git a/tools/agents.py b/tools/agents.py index c12398a..45fe6a5 100644 --- a/tools/agents.py +++ b/tools/agents.py @@ -98,7 +98,7 @@ def make_party(did: str, resolver: pc.KeyResolver, client: Client, # -------------------------------------------------------------------------- def draft_contract(vid: str, buyer: str, seller: str, facilitator: str, - verifier: str, *, price: str = "180.00", bond: str = "18.00", + verifier: str | None, *, price: str = "180.00", bond: str = "18.00", fund: str = "0.50", q_min: float = 0.9091, deadline: str = "2027-01-01T00:00:00Z", release: str = "on-verification", @@ -106,14 +106,14 @@ def draft_contract(vid: str, buyer: str, seller: str, facilitator: str, spec_hash: str | None = None, criteria_hash: str | None = None) -> dict: """An unsigned contract in the shape schemas/vtc.schema.json requires.""" + parties = {"buyer": buyer, "seller": seller, "facilitator": facilitator} + if verifier is not None: + parties["verifier"] = verifier # absent: Section 9.1 is derived per signer return { "pact": "0.1", "type": "VerifiableTaskContract", "id": vid, - "parties": { - "buyer": buyer, "seller": seller, - "facilitator": facilitator, "verifier": verifier, - }, + "parties": parties, "task": { "spec_hash": spec_hash or pc.h(b"taskspec placeholder"), "spec_uri": "https://buyer.example/specs/taskspec.json", diff --git a/tools/facilitator.py b/tools/facilitator.py index 0d25b0c..3f733ea 100644 --- a/tools/facilitator.py +++ b/tools/facilitator.py @@ -8,10 +8,10 @@ Section 1.4. Two independent implementations settling each other's contracts is that experiment. This is the first half of it. -What it enforces, with the section each rule comes from, is listed in RULES -below. Every refusal is an RFC 9457 problem document naming the rule that was -violated, because a Facilitator that refuses without saying why cannot be -debugged against by a second implementer. +What it enforces, with the section each rule comes from, is listed in RULES. +Where the draft is silent an implementer has to choose; every such choice is +listed in CHOICES and repeated in tools/README.md, because a choice presented +as a rule is how a second implementer ends up disagreeing with the first. Storage is in memory. Money is an integer number of cents in three pools. No payment rail is touched: Section 1.2 puts the rail out of scope, and a @@ -29,66 +29,125 @@ import argparse import json +import pathlib import re import threading import time from dataclasses import dataclass, field +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any import pactcore as pc +ROOT = pathlib.Path(__file__).resolve().parent.parent + RULES = """ +Section 5 a VTC is valid only if buyer and seller each signed it once; nobody else signs it Section 5.3 liability is REQUIRED, and a contract without it is not a PACT contract -Section 7.1 cumulative release before a recorded Verdict MUST NOT exceed the Bond -Section 7.2 the assurance constraint is evaluated BEFORE funds lock, and a contract - that fails it is refused -Section 7.4 the five-rank remedy waterfall, restitution before bounty or remainder -Section 7.5 the Bond is returned when the contract reaches FINAL or SETTLED -Section 6 deadline expiry with no Delivery moves the contract to ABANDONED -Section 9.1 verifier independence is DERIVED by comparing normalized party - identifiers, never read from a field +Section 6 evidence absent or nonconformant: refuse the Delivery AND apply 7.4 as though FAIL; + input_hash REQUIRED where the tier re-executes; deadline with no Delivery: ABANDONED +Section 7.1 cumulative release before a recorded Verdict never exceeds the Bond (on-verification + releases nothing before a Verdict, so the cap is satisfied by construction) +Section 7.2 the assurance constraint is evaluated exactly, BEFORE funds lock; failure is refused +Section 7.3 release modes this Facilitator does not advertise are refused at propose +Section 7.4 the five-rank remedy waterfall, restitution before bounty or remainder; a bounty is + paid only to a Challenger that exists; nothing above liability.cap leaves the Seller +Section 7.5 a Challenge is a fraud proof submitted for evaluation, accepted only inside the + window, only with a proof conformant to the profile, and it settles nothing itself; + a Verdict on a Challenge supersedes the earlier one and both are kept +Section 7.6 the Bond is returned when the contract reaches FINAL or SETTLED, less what 7.4 took +Section 8 the capability document is signed and validates against facilitator.schema.json +Section 9.1 verifier independence is DERIVED by comparing normalized party identifiers, at + propose for a named verifier and at Verdict for the signer; never read from a field Section 3 a Facilitator MUST NOT act as Verifier for a contract it settles -Section 11 an attestation is issued for every terminal contract, is signed by the - Facilitator, and does not require the signature of the party whose loss - it records -Section 12.2 a POST whose body canonicalizes to a known hash returns 200 and the - existing resource; the same id with a different hash returns 409 +Section 10.1 a contract carrying liability.parent is refused: subcontracts are NOT implemented +Section 11 an attestation is issued for every terminal contract, signed by the Facilitator, + never requiring the signature of the party whose loss it records +Section 12.1 every posted object validates against its published schema and the 13.2 checks +Section 12.2 a POST whose body canonicalizes to a known digest returns 200 and the CURRENT + resource; the same id with a different digest returns 409 Section 12.3 every failure is an RFC 9457 problem document naming the rule -Section 12.4 a Verdict signer MUST satisfy Section 9.1, and a Verdict for a contract - with no recorded Delivery is rejected -Section 13.1 JWS with a detached payload, an algorithm allowlist, and kid inside the - protected header +Section 12.4 a Verdict signer MUST satisfy 9.1 (or be the named verifier); no Verdict without + a recorded Delivery; a Verdict commits to the contract's instrument and profile +Section 13.1 JWS with a detached payload over the TRANSMITTED protected header, an algorithm + allowlist, kid inside the protected header, typ compared to the media type +Section 16.7 a contract naming another Facilitator, or a settlement, network or asset this one + does not advertise, is refused +Section 16.11 the public key resolved for every accepted signature is recorded with the object +""" + +CHOICES = """ +Where the -01 text is silent or inconsistent this implementation chose, and says so: +C1 ABANDONED: the Bond is slashed to the extent of restitution_basis (zero under `released` + with nothing released) and the rest is RETURNED. Section 7.6 returns the Bond only on + FINAL or SETTLED and says nothing about ABANDONED. +C2 Rank 3 restores the Buyer's LOSS up to the basis, net of what rank 1 already returned. + Read literally, basis `price` would pay the Bond on top of a reversed escrow. +C3 Rank 4 pays the whole remaining Bond as the bounty, split equally among the successful + Challengers. The draft bounds the bounty and does not fix it; with K discoverers the + non-exclusive full bounty is not fundable from one Bond. +C4 Rank 2 pays 0.00: the Challenge object has no member for documented costs. +C5 A Challenge that receives no Verdict within max_dispute_seconds lapses and the contract + returns to RELEASING; the window it interrupted is not extended. +C6 PROPOSED is never observable: with no rail the pools are debited in memory the moment a + co-signed contract is accepted, so the 201 reports FUNDED. +C7 Amounts are settled in whole cents; a contract with more decimal places is refused. +C8 Not implemented: subcontracts (Section 10), release modes other than on-verification, + challenge deposits, committed-sample assurance, network key resolution, any rail. """ -# Section 18.5: "This document creates no registry for its problem types." A -# URL that 404s is worse than an opaque identifier, so these are document-local -# URNs until a registry exists. RFC 9457 permits any URI and does not require -# it to dereference. -PROBLEM_BASE = "urn:pact:problem:" +# Section 18.5: identifiers are appended to this prefix, which the draft owns. +PROBLEM_BASE = "https://pact-spec.github.io/problem/" -# Section 18.5 reserves these names but does not create a registry; the draft -# asks IANA to create one on publication. Until then these are the document's -# own strings and are stable within this implementation. +# Table 9 entries first, with the status and section the draft assigns; then the +# document-local types this implementation needs, each naming the section whose +# rule it reports. Section 18.5 permits a document-local namespace. PROBLEMS = { "assurance-constraint-unsatisfied": (422, "Section 7.2"), + "evidence-nonconformant": (422, "Section 6"), + "parent-unresolvable": (422, "Section 10.1"), + "finality-ordering-violation": (422, "Section 10.3"), + "parties-not-distinct": (422, "Section 13.2"), + "algorithm-not-permitted": (400, "Section 13.1"), + "verifier-not-independent": (422, "Section 9.1"), + "release-exceeds-bond": (409, "Section 7.1"), + # document-local + "schema-invalid": (422, "Section 13.2"), "liability-missing": (422, "Section 5.3"), - "parties-not-distinct": (422, "Section 9.1"), "signature-invalid": (401, "Section 13.1"), "signature-missing": (401, "Section 13.1"), - "verifier-not-independent": (422, "Section 9.1"), + "unexpected-signer": (422, "Section 5"), "facilitator-cannot-verify": (422, "Section 3"), + "facilitator-mismatch": (422, "Section 16.7"), + "settlement-unsupported": (422, "Section 8"), + "release-mode-unsupported": (422, "Section 7.3"), + "assurance-unsupported": (422, "Section 7.2"), + "deadline-invalid": (422, "Section 13.2"), + "amount-invalid": (422, "Section 13.2"), "no-recorded-delivery": (409, "Section 12.4"), + "verdict-nonconformant": (422, "Section 12.4"), + "proof-nonconformant": (422, "Section 7.5"), + "challenge-window-closed": (409, "Section 7.5"), "wrong-state": (409, "Section 12"), "object-conflict": (409, "Section 12.2"), - "challenge-window-closed": (409, "Section 7.4"), - "evidence-nonconformant": (422, "Section 6"), - "release-exceeds-bond": (422, "Section 7.1"), "unknown-contract": (404, "Section 12"), + "payload-too-large": (413, "Section 12"), + "internal-error": (500, "Section 12"), } TERMINAL = ("FINAL", "SETTLED", "ABANDONED") +MEDIA_CONTRACT = "application/pact-contract+json" +MEDIA_DELIVERY = "application/pact-delivery+json" +MEDIA_VERDICT = "application/pact-verdict+json" +MEDIA_CHALLENGE = "application/pact-challenge+json" +MEDIA_ATTESTATION = "application/pact-attestation+json" +MEDIA_FACILITATOR = "application/pact-facilitator+json" + +MAX_BODY = 1 << 20 # one MiB; a contract is under two KB + class Refuse(Exception): def __init__(self, kind: str, detail: str, **extra: Any) -> None: @@ -98,17 +157,85 @@ def __init__(self, kind: str, detail: str, **extra: Any) -> None: super().__init__(detail) +# -------------------------------------------------------------------------- +# Schemas. The published ones, loaded once. The Facilitator refuses to start +# without jsonschema because Section 12.1 makes schema conformance a MUST at +# Propose and a Facilitator that skips it is not one. +# -------------------------------------------------------------------------- + +class Schemas: + def __init__(self) -> None: + try: + from jsonschema import Draft202012Validator + from referencing import Registry, Resource + except ImportError as exc: # pragma: no cover + raise RuntimeError( + "the reference Facilitator needs `jsonschema` and `referencing` " + "to validate posted objects; pip install them") from exc + docs = {p.name: json.loads(p.read_text()) + for p in (ROOT / "schemas").glob("*.schema.json")} + registry = Registry().with_resources( + [(name, Resource.from_contents(s)) for name, s in docs.items()]) + self._v = {name: Draft202012Validator(s, registry=registry) + for name, s in docs.items()} + + def check(self, obj: Any, name: str) -> None: + errors = sorted(self._v[name].iter_errors(obj), key=lambda e: list(e.path)) + if errors: + e = errors[0] + where = "/".join(str(x) for x in e.path) or "(root)" + raise Refuse("schema-invalid", + f"does not validate against {name} at {where}: {e.message}", + schema=name, path=where) + + +# -------------------------------------------------------------------------- + +def parse_rfc3339(s: str) -> float: + """RFC 3339 to a POSIX timestamp, UTC. Accepts Z, an offset, fractions. + + An earlier version used time.mktime(strptime(...)), which interprets the + string in the host's local zone and accepts one fixed format, so ABANDONED + fired hours early or late depending on where the Facilitator ran. + """ + try: + if s.endswith("Z") or s.endswith("z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + except (ValueError, TypeError) as exc: + raise Refuse("deadline-invalid", f"task.deadline {s!r} is not RFC 3339") from exc + if dt.tzinfo is None: + raise Refuse("deadline-invalid", f"task.deadline {s!r} carries no zone") + return dt.astimezone(timezone.utc).timestamp() + + +def iso(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def money_cents(label: str, amount: Any) -> int: + try: + return pc.cents(amount) + except (ValueError, InvalidOperation, TypeError) as exc: + raise Refuse("amount-invalid", + f"{label} {amount!r}: this Facilitator settles in whole cents " + f"and refuses amounts it cannot represent") from exc + + @dataclass class Contract: vtc: dict state: str = "PROPOSED" pools: pc.Pools = field(default_factory=pc.Pools) delivery: dict | None = None - verdict: dict | None = None - challenge: dict | None = None + verdicts: list[dict] = field(default_factory=list) + challenges: list[dict] = field(default_factory=list) attestation: dict | None = None window_opened_at: float | None = None - created_at: float = field(default_factory=time.time) + disputed_at: float | None = None + created_at: float = 0.0 + deadline: float = 0.0 + keys: dict[str, str] = field(default_factory=dict) # Section 16.11 record @property def id(self) -> str: @@ -122,6 +249,22 @@ def buyer(self) -> str: def seller(self) -> str: return self.vtc["parties"]["seller"] + @property + def verdict(self) -> dict | None: + return self.verdicts[-1] if self.verdicts else None + + @property + def challenge(self) -> dict | None: + return self.challenges[-1] if self.challenges else None + + @property + def window_seconds(self) -> int: + return int(self.vtc["challenge"]["window_seconds"]) + + @property + def max_dispute_seconds(self) -> int: + return int(self.vtc["challenge"].get("max_dispute_seconds", 0)) + def digest(self) -> str: return pc.digest_over(pc.hashable(self.vtc)) @@ -130,352 +273,543 @@ class Facilitator: """The settlement service. Thread safe under the threading HTTP server.""" def __init__(self, identity: str, key: pc.Key, resolver: pc.KeyResolver, - now: Any = time.time) -> None: + now: Any = time.time, base_url: str = "http://127.0.0.1:8402") -> None: self.identity = identity self.key = key self.resolver = resolver self.now = now + self.base_url = base_url.rstrip("/") + self.schemas = Schemas() self.contracts: dict[str, Contract] = {} - self.seen: dict[str, tuple[str, dict]] = {} # object digest -> (kind, body) + self.seen: dict[tuple[str, str], str] = {} # (kind, digest) -> vtc_id self.lock = threading.RLock() self.message_count = 0 + # What this Facilitator advertises, Section 8. Contracts outside it are + # refused at propose rather than accepted and stranded. + self.settlement_bindings = [ + {"id": "pact-escrow", "networks": ["eip155:8453"], "assets": ["USDC"]}] + self.release_modes = ["on-verification"] + self.verification_profiles = ["acceptance"] + self.assurance_modes = ["certain"] + self.max_contract_value = {"amount": "50000.00", "currency": "USDC"} # -- Section 12.2 ------------------------------------------------------ - def _idempotent(self, kind: str, obj: dict) -> dict | None: - digest = pc.digest_over(pc.hashable(obj)) - hit = self.seen.get(digest) - if hit is not None: - return hit[1] - return None + def _digest(self, obj: dict) -> str: + return pc.digest_over(pc.hashable(obj)) + + def _remember(self, kind: str, obj: dict, vid: str) -> None: + self.seen[(kind, self._digest(obj))] = vid + + def _replay(self, kind: str, obj: dict) -> tuple[int, dict] | None: + """200 with the CURRENT resource, not a snapshot taken at creation.""" + vid = self.seen.get((kind, self._digest(obj))) + if vid is None: + return None + c = self.contracts[vid] + self._tick(c) + if kind == "contract": + return 200, self._with_state(c, c.vtc) + if kind == "delivery": + return 200, self._with_state(c, c.delivery) + if kind == "verdict": + return 200, self._with_state(c, obj) + return 200, self._with_state(c, obj) + + def _with_state(self, c: Contract, obj: dict) -> dict: + out = dict(obj) + out["state"] = c.state # Section 12: added here, never signed or hashed + return out - def _remember(self, kind: str, obj: dict, body: dict) -> None: - self.seen[pc.digest_over(pc.hashable(obj))] = (kind, body) + # -- time ---------------------------------------------------------------- + def _tick(self, c: Contract) -> None: + """Advance the contract along every clock-driven edge that is due.""" + self._expire_if_due(c) + self._lapse_dispute_if_due(c) + self._close_window_if_due(c) + + def _record_keys(self, c: Contract, obj: dict) -> None: + # Section 16.11: record the key material resolved at acceptance, so a + # later rotation or revocation does not orphan a signature already + # accepted. The resolver here is in-process; the record is real. + for kid in pc.signer_kids(obj): + key = self.resolver.resolve(kid) + if key is not None and kid not in c.keys: + c.keys[kid] = pc.b64u(pc.public_bytes(key)) # -- Propose, Section 12.1 -------------------------------------------- def propose(self, vtc: dict) -> tuple[int, dict]: with self.lock: - existing = self._idempotent("contract", vtc) - if existing is not None: - return 200, existing + hit = self._replay("contract", vtc) + if hit is not None: + return hit - vid = vtc.get("id") + self.schemas.check(vtc, "vtc.schema.json") + vid = vtc["id"] if vid in self.contracts: raise Refuse("object-conflict", - f"contract {vid} exists with a different hash") - - liability = vtc.get("liability") - if not liability: - raise Refuse("liability-missing", - "a contract that does not allocate liability is not " - "a PACT contract") + f"contract {vid} exists with a different digest") + if vtc.get("pact") != "0.1" or vtc.get("type") != "VerifiableTaskContract": + raise Refuse("schema-invalid", "pact version or type is not one this " + "Facilitator implements") - buyer = vtc["parties"]["buyer"] - seller = vtc["parties"]["seller"] + parties = vtc["parties"] + buyer, seller = parties["buyer"], parties["seller"] if pc.same_party(buyer, seller): raise Refuse("parties-not-distinct", "buyer and seller are the same party after the " - "normalization of Section 9.1", - buyer=buyer, seller=seller) + "normalization of Section 9.1", buyer=buyer, seller=seller) + if not pc.same_party(parties["facilitator"], self.identity): + raise Refuse("facilitator-mismatch", + "the contract names a different Facilitator; accepting it " + "would make this signed instrument replayable across venues", + named=parties["facilitator"], this=self.identity) + named = parties.get("verifier") + if named: + for who, label in ((buyer, "buyer"), (seller, "seller"), + (self.identity, "facilitator")): + if pc.same_party(named, who): + raise Refuse("verifier-not-independent", + f"parties.verifier is the {label} after " + f"normalization; such a contract could never be " + f"verified", verifier=named) - ok, why = pc.verify_object(vtc, self.resolver, - "application/pact-contract+json", - [buyer, seller]) + liability = vtc.get("liability") + if not liability: + raise Refuse("liability-missing", "a contract that does not allocate " + "liability is not a PACT contract") + if "parent" in liability: + raise Refuse("parent-unresolvable", + "subcontracts (Section 10) are not implemented by this " + "Facilitator; a contract naming a parent is refused rather " + "than accepted with the parent ignored") + + price_m = vtc["price"] + binding = next((b for b in self.settlement_bindings + if b["id"] == price_m["settlement"]), None) + if (binding is None or price_m["network"] not in binding["networks"] + or price_m["currency"] not in binding["assets"]): + raise Refuse("settlement-unsupported", + "settlement, network or asset is not one this Facilitator " + "advertises in its capability document", + settlement=price_m["settlement"], network=price_m["network"], + currency=price_m["currency"]) + if vtc["release"] not in self.release_modes: + raise Refuse("release-mode-unsupported", + f"release mode {vtc['release']!r} is not implemented; " + f"accepting it would strand the contract in RELEASING", + supported=self.release_modes) + mode = vtc["assurance"]["mode"] + if mode == "open": + raise Refuse("assurance-unsupported", + "a contract MUST NOT declare open as its sole source of " + "assurance (Section 7.2)") + if mode not in self.assurance_modes: + raise Refuse("assurance-unsupported", + f"assurance mode {mode!r} is not implemented", + supported=self.assurance_modes) + if vtc["verification"]["profile"] not in self.verification_profiles: + raise Refuse("settlement-unsupported", + f"verification profile {vtc['verification']['profile']!r} " + f"is not one this Facilitator supports", + supported=self.verification_profiles) + + deadline = parse_rfc3339(vtc["task"]["deadline"]) + if deadline <= self.now(): + raise Refuse("deadline-invalid", + "task.deadline is already past on this Facilitator's clock; " + "the contract would be ABANDONED the moment it was funded") + + price = money_cents("price.amount", price_m["amount"]) + bond = money_cents("liability.seller_bond", liability["seller_bond"]) + fund = money_cents("liability.verification_fund", liability["verification_fund"]) + cap = money_cents("liability.cap", liability["cap"]) + maxv = money_cents("max_contract_value", self.max_contract_value["amount"]) + if price > maxv: + raise Refuse("settlement-unsupported", + f"price exceeds this Facilitator's max_contract_value " + f"{self.max_contract_value['amount']}") + + # Section 5: exactly the Buyer's and the Seller's signatures, each + # once, verifying against keys bound to those identifiers. A third + # signer changes the digest without changing the agreement. + ok, why = pc.verify_object(vtc, self.resolver, MEDIA_CONTRACT, [buyer, seller]) if not ok: - raise Refuse("signature-invalid", why) - - price = float(vtc["price"]["amount"]) - bond = float(liability["seller_bond"]) - fund = float(liability["verification_fund"]) - q_min = float(vtc["assurance"]["q_min"]) - - # Section 7.2: evaluated BEFORE funds lock. This is the one check - # that makes a bond size a claim a Facilitator can refuse rather - # than a number a Seller asserts. - if not pc.assurance_holds(price, bond, q_min, released=0.0): - need = pc.required_bond(price, q_min, 0.0) + raise Refuse(_sig_kind(why), why) + for kid in pc.signer_kids(vtc): + if not (pc.kid_covers(kid, buyer) or pc.kid_covers(kid, seller)): + raise Refuse("unexpected-signer", + "the contract carries a signature from a party that is " + "neither its Buyer nor its Seller", signer=kid) + + # Section 7.2: evaluated exactly, BEFORE funds lock. + q_min = vtc["assurance"]["q_min"] + if not pc.assurance_holds(price_m["amount"], liability["seller_bond"], + q_min, "0"): + need = pc.required_bond(float(price_m["amount"]), float(q_min), 0.0) raise Refuse( "assurance-constraint-unsatisfied", - f"Bond {bond:.2f} is below the minimum {need:.2f} required " - f"for q_min {q_min:.2f} at price {price:.2f} with E 0.00.", - required_bond=f"{need:.2f}", declared_bond=f"{bond:.2f}", - q_min=q_min, price=f"{price:.2f}") - - c = Contract(vtc=vtc) - c.pools.escrow = pc.cents(price) - c.pools.bond = pc.cents(bond) - c.pools.bond_initial = pc.cents(bond) - c.pools.fund = pc.cents(fund) - c.pools.note(f"locked escrow {price:.2f}, bond {bond:.2f}, fund {fund:.2f}") + f"Bond {liability['seller_bond']} is below the minimum {need:.2f} " + f"required for q_min {float(q_min):.2f} at price " + f"{price_m['amount']} with E 0.00.", + required_bond=f"{need:.2f}", declared_bond=liability["seller_bond"], + q_min=q_min, price=price_m["amount"]) + + c = Contract(vtc=vtc, created_at=self.now(), deadline=deadline) + c.pools.escrow = price + c.pools.bond = bond + c.pools.bond_initial = bond + c.pools.fund = fund + c.pools.cap = cap + c.pools.note(f"locked escrow {pc.money(price)}, bond {pc.money(bond)}, " + f"fund {pc.money(fund)} (in memory: no rail, so PROPOSED is " + f"not observable and the contract is FUNDED at once)") c.state = "FUNDED" + self._record_keys(c, vtc) self.contracts[c.id] = c - body = self._contract_body(c) - self._remember("contract", vtc, body) - return 201, body - - def _contract_body(self, c: Contract) -> dict: - out = dict(c.vtc) - out["state"] = c.state # Section 12: added here, never signed or hashed - return out + self._remember("contract", vtc, c.id) + return 201, self._with_state(c, vtc) # -- Retrieve ---------------------------------------------------------- def get_contract(self, vid: str) -> tuple[int, dict]: with self.lock: - c = self.contracts.get(vid) - if c is None: - raise Refuse("unknown-contract", f"no contract {vid}") - self._expire_if_due(c) - return 200, self._contract_body(c) + c = self._contract(vid) + self._tick(c) + return 200, self._with_state(c, c.vtc) + + def _contract(self, vid: str) -> Contract: + c = self.contracts.get(vid) + if c is None: + raise Refuse("unknown-contract", f"no contract {vid}") + return c # -- Section 6: deadline expiry --------------------------------------- def _expire_if_due(self, c: Contract) -> None: - if c.state != "FUNDED": - return - deadline = c.vtc.get("task", {}).get("deadline") - if not deadline: + if c.state != "FUNDED" or self.now() < c.deadline: return - due = time.mktime(time.strptime(deadline, "%Y-%m-%dT%H:%M:%SZ")) - if self.now() < due: - return - # Nothing was delivered and the deadline passed. The escrow goes back - # and the Bond is slashed to the extent of restitution_basis. Under - # basis "released" with nothing released that is zero, which is the - # honest reading of the current example and a live -02 question. - c.pools.paid_to_buyer += c.pools.escrow - c.pools.note(f"deadline {deadline} passed with no Delivery, " - f"returned escrow {pc.money(c.pools.escrow)} to buyer") - c.pools.escrow = 0 - basis = c.vtc["liability"]["restitution_basis"] - owed = c.pools.released if basis == "released" else pc.cents(c.vtc["price"]["amount"]) - slashed = min(owed, c.pools.bond) - c.pools.bond -= slashed - c.pools.paid_to_buyer += slashed - c.pools.restituted += slashed - c.pools.note(f"restitution basis {basis!r} gives {pc.money(slashed)} from bond") - self._return_bond(c) + p = c.pools + p.paid_to_buyer += p.escrow + p.note(f"deadline {iso(c.deadline)} passed with no Delivery: returned escrow " + f"{pc.money(p.escrow)} to buyer") + p.escrow = 0 + # Section 6: slash the Bond to the extent of restitution_basis. Under + # `released` with nothing released that extent is zero. What happens to + # the rest is unspecified (Section 7.6 covers FINAL and SETTLED only); + # CHOICES C1: it is returned. + owed = self._basis_owed(c) + slashed = min(owed, p.bond, p.cap) + if slashed: + p.bond -= slashed + p.paid_to_buyer += slashed + p.restituted += slashed + p.note(f"restitution basis {c.vtc['liability']['restitution_basis']!r} slashes " + f"{pc.money(slashed)} from the bond on ABANDONED (Section 6)") + self._return_bond(c, "C1: the draft does not say; returned") c.state = "ABANDONED" self._attest(c, outcome="abandoned") - def _return_bond(self, c: Contract) -> None: - # Section 7.5: the Bond is returned on finality. The -00 had no rule - # returning it at all. - if c.pools.bond: - c.pools.note(f"returned bond {pc.money(c.pools.bond)} to seller") - c.pools.bond_returned += c.pools.bond - c.pools.bond = 0 - if c.pools.fund: - c.pools.note(f"returned verification fund {pc.money(c.pools.fund)}") - c.pools.fund_returned += c.pools.fund - c.pools.fund = 0 + def _basis_owed(self, c: Contract) -> int: + """Rank 3: the Buyer's loss, up to the basis, net of rank 1 (CHOICES C2).""" + p = c.pools + basis = c.vtc["liability"]["restitution_basis"] + ceiling = p.released if basis == "released" else pc.cents(c.vtc["price"]["amount"]) + loss = pc.cents(c.vtc["price"]["amount"]) - p.paid_to_buyer # escrow not yet back + return max(0, min(ceiling, loss)) + + def _return_bond(self, c: Contract, why: str) -> None: + # Section 7.6: returned at FINAL or SETTLED, less what 7.4 applied. + p = c.pools + if p.bond: + p.note(f"returned bond {pc.money(p.bond)} to seller ({why})") + p.bond_returned += p.bond + p.bond = 0 + if p.fund: + p.note(f"returned unspent verification fund {pc.money(p.fund)} to seller") + p.fund_returned += p.fund + p.fund = 0 + + # -- the challenge window, Section 7.5 -------------------------------- + def _close_window_if_due(self, c: Contract) -> None: + if c.state != "RELEASING" or c.window_opened_at is None: + return + if self.now() - c.window_opened_at < c.window_seconds: + return + c.pools.note(f"challenge window of {c.window_seconds}s closed with no " + f"successful Challenge") + self._return_bond(c, "Section 7.6, FINAL") + c.state = "FINAL" + self._attest(c, outcome="performed") + + def _lapse_dispute_if_due(self, c: Contract) -> None: + # CHOICES C5. The draft bounds a dispute by max_dispute_seconds and does + # not say what happens when the bound passes with no Verdict. + if c.state != "DISPUTED" or c.disputed_at is None or not c.max_dispute_seconds: + return + if self.now() - c.disputed_at < c.max_dispute_seconds: + return + c.pools.note(f"no Verdict within max_dispute_seconds {c.max_dispute_seconds}; " + f"the Challenge lapses and the earlier Verdict stands (C5)") + c.disputed_at = None + c.state = "RELEASING" # -- Submit Delivery, Section 12 -------------------------------------- def submit_delivery(self, dlv: dict) -> tuple[int, dict]: with self.lock: - existing = self._idempotent("delivery", dlv) - if existing is not None: - return 200, existing - - c = self._contract_for(dlv) - self._expire_if_due(c) + hit = self._replay("delivery", dlv) + if hit is not None: + return hit + for member in ("vtc_id", "vtc_hash", "signature"): + if not isinstance(dlv.get(member), (str, dict)): + raise Refuse("schema-invalid", f"the Delivery lacks {member}") + c = self._contract(dlv["vtc_id"]) + self._tick(c) + if dlv["vtc_hash"] != c.digest(): + raise Refuse("object-conflict", "vtc_hash does not commit to this contract", + expected=c.digest(), received=dlv["vtc_hash"]) if c.state != "FUNDED": - raise Refuse("wrong-state", - f"contract {c.id} is {c.state}, a Delivery is only " - f"accepted in FUNDED") + raise Refuse("wrong-state", f"contract {c.id} is {c.state}; a Delivery " + f"is only accepted in FUNDED") - # A Delivery not signed by the contract's Seller is rejected. - ok, why = pc.verify_object(dlv, self.resolver, - "application/pact-delivery+json", [c.seller]) + # Section 12: a Delivery not signed by the contract's Seller is + # rejected, and nobody else may sign it. + ok, why = pc.verify_object(dlv, self.resolver, MEDIA_DELIVERY, [c.seller]) if not ok: - raise Refuse("signature-invalid", why) - - # Section 6: the Delivery is the thing being judged, and evidence - # must conform to the profile the contract declares. Without this - # the -00's cheapest attack, deliver nothing verifiable, is open - # again. validate.py's negative vector V-14 covers the same rule. - # Draft line 832: "A Facilitator MUST reject a Delivery whose - # evidence is absent or does not conform to the profile named in the - # VTC, and MUST apply Section 7.4 as though a FAIL Verdict had been - # recorded." Both halves are normative. Refusing without applying - # the waterfall would leave the contract sitting in FUNDED with the - # Buyer's escrow locked, which is the outcome the rule exists to - # prevent. This is the rule the draft calls the one that makes - # silence expensive. - evidence = dlv.get("evidence") - declared = c.vtc["verification"]["profile"] - reason = None - if not isinstance(evidence, dict): - reason = "the Delivery carries no evidence member" - elif evidence.get("profile") != declared: - reason = (f"evidence profile {evidence.get('profile')!r} does not " - f"match the contract's declared profile {declared!r}") - elif evidence.get("instrument_hash") != \ - c.vtc["verification"]["criteria_hash"]: - reason = ("the evidence does not commit to the instrument the " - "contract committed to") + raise Refuse(_sig_kind(why), why) + + # Section 6: shape, not substance. Absent or nonconformant evidence + # is refused AND remedied as though a FAIL Verdict had been recorded, + # which is the rule the draft calls the one that makes silence + # expensive. Both halves are normative (line 832). + reason = self._delivery_nonconformance(c, dlv) + if reason is None: + # Shape of everything else: a schema failure inside `evidence` + # is nonconformance too; elsewhere it is a plain 13.2 refusal. + try: + self.schemas.check(dlv, "delivery.schema.json") + except Refuse as exc: + if str(exc.extra.get("path", "")).startswith("evidence"): + reason = exc.detail + else: + raise if reason is not None: - c.pools.note(f"nonconformant Delivery: {reason}; applying " - f"Section 7.4 as though a FAIL Verdict were recorded") - self._apply_waterfall(c, challenger=c.buyer, costs=0) - raise Refuse("evidence-nonconformant", reason, - state=c.state, + c.pools.note(f"nonconformant Delivery: {reason}; applying Section 7.4 as " + f"though a FAIL Verdict were recorded") + self._record_keys(c, dlv) + self._apply_waterfall(c, challengers=[]) + raise Refuse("evidence-nonconformant", reason, state=c.state, remedy="Section 7.4 applied as though FAIL") c.delivery = dlv c.state = "DELIVERED" - body = dict(dlv) - body["state"] = c.state - self._remember("delivery", dlv, body) - return 202, body - - def _contract_for(self, obj: dict) -> Contract: - """Resolve the contract an object refers to, and check its commitment. - - The objects commit differently and the schemas say so. A Delivery - carries vtc_hash and commits to the contract. A Verdict and a Challenge - carry delivery_hash and commit to the Delivery being judged, which is - the right thing to bind: a Verdict is a statement about a Delivery. - """ - c = self.contracts.get(obj.get("vtc_id")) - if c is None: - raise Refuse("unknown-contract", f"no contract {obj.get('vtc_id')}") + self._record_keys(c, dlv) + self._remember("delivery", dlv, c.id) + return 202, self._with_state(c, dlv) + + def _delivery_nonconformance(self, c: Contract, dlv: dict) -> str | None: + ev = dlv.get("evidence") + ver = c.vtc["verification"] + if not isinstance(ev, dict): + return "the Delivery carries no evidence member" + if ev.get("profile") != ver["profile"]: + return (f"evidence profile {ev.get('profile')!r} does not match the " + f"contract's declared profile {ver['profile']!r}") + if ev.get("instrument_hash") != ver["criteria_hash"]: + return "the evidence does not commit to the instrument the contract committed to" + if "results_hash" not in ev: + return "the acceptance profile requires results_hash in the evidence" + if "results_uri" in ev and "results_hash" not in ev: + return "results_uri without a sibling results_hash (Section 5.1)" + if ver["tier"] == "T0-reexec" and "input_hash" not in dlv: + return ("input_hash is REQUIRED for a tier whose fraud proof re-executes " + "(Section 6)") + return None - if "vtc_hash" in obj: - if obj["vtc_hash"] != c.digest(): - raise Refuse("object-conflict", - "vtc_hash does not commit to this contract", - expected=c.digest(), received=obj["vtc_hash"]) - elif "delivery_hash" in obj: - if c.delivery is None: - raise Refuse("no-recorded-delivery", - f"contract {c.id} has no recorded Delivery to judge") - recorded = pc.digest_over(pc.hashable(c.delivery)) - if obj["delivery_hash"] != recorded: - raise Refuse("object-conflict", - "delivery_hash does not commit to the recorded Delivery", - expected=recorded, received=obj["delivery_hash"]) - return c + def _delivery_digest(self, c: Contract) -> str: + if c.delivery is None: + raise Refuse("no-recorded-delivery", + f"contract {c.id} has no recorded Delivery to judge") + return pc.digest_over(pc.hashable(c.delivery)) # -- Record Verdict, Section 12.4 ------------------------------------- def record_verdict(self, verdict: dict) -> tuple[int, dict]: with self.lock: - existing = self._idempotent("verdict", verdict) - if existing is not None: - return 200, existing - - c = self._contract_for(verdict) + hit = self._replay("verdict", verdict) + if hit is not None: + return hit + self.schemas.check(verdict, "verdict.schema.json") + c = self._contract(verdict["vtc_id"]) + self._tick(c) + recorded = self._delivery_digest(c) + if verdict["delivery_hash"] != recorded: + raise Refuse("object-conflict", + "delivery_hash does not commit to the recorded Delivery", + expected=recorded, received=verdict["delivery_hash"]) if c.state not in ("DELIVERED", "DISPUTED"): raise Refuse("wrong-state", - f"contract {c.id} is {c.state}; a Verdict is accepted " - f"on DELIVERED, or on DISPUTED to resolve a challenge") + f"contract {c.id} is {c.state}; a Verdict is accepted on " + f"DELIVERED, or on DISPUTED to resolve a Challenge") + + # A Verdict commits to the instrument it ran (Section 12.4). One + # over a different instrument or profile is the Section 16.3 + # substitution attack from the verifier's side. + ver = c.vtc["verification"] + if verdict["instrument_hash"] != ver["criteria_hash"]: + raise Refuse("verdict-nonconformant", + "instrument_hash is not the instrument the contract committed to", + expected=ver["criteria_hash"], received=verdict["instrument_hash"]) + if verdict["profile"] != ver["profile"]: + raise Refuse("verdict-nonconformant", + f"profile {verdict['profile']!r} is not the contract's " + f"{ver['profile']!r}") + if verdict["outcome"] not in ("PASS", "FAIL"): + raise Refuse("verdict-nonconformant", "outcome must be PASS or FAIL") kids = pc.signer_kids(verdict) if not kids: raise Refuse("signature-missing", "the Verdict carries no signature") - - # Draft line 1852: "The Verifier is the party identified by the kid - # of the Verdict's signature. Where the contract names - # parties.verifier, the Verdict MUST be signed by that party; - # otherwise the Facilitator evaluates Section 9.1 against the - # signer." An earlier version checked only that the signer was not - # the Seller or the Facilitator, so any resolvable key could pass or - # fail any contract. + self._check_verdict_signer(c, kids) named = c.vtc["parties"].get("verifier") - if named: - if not any(pc.kid_covers(k, named) for k in kids): - raise Refuse("verifier-not-independent", - "the contract names a verifier and the Verdict " - "is not signed by that party", - signer=kids[0], required_verifier=named) - for kid in kids: - if pc.kid_covers(kid, c.seller): - raise Refuse("verifier-not-independent", - "the Verdict is signed by the contract's Seller", - signer=kid, seller=c.seller) - if pc.kid_covers(kid, c.buyer): - raise Refuse("verifier-not-independent", - "the Verdict is signed by the contract's Buyer", - signer=kid, buyer=c.buyer) - if pc.kid_covers(kid, self.identity): - raise Refuse("facilitator-cannot-verify", - "a Facilitator MUST NOT act as Verifier for a " - "contract it settles", - signer=kid, facilitator=self.identity) - - ok, why = pc.verify_object( - verdict, self.resolver, "application/pact-verdict+json", - [named] if named else []) + ok, why = pc.verify_object(verdict, self.resolver, MEDIA_VERDICT, + [named] if named else []) if not ok: - raise Refuse("signature-invalid", why) + raise Refuse(_sig_kind(why), why) - c.verdict = verdict + # Every check passed; only now does state move. + c.verdicts.append(verdict) # Section 7.5: both are recorded + self._record_keys(c, verdict) outcome = verdict["outcome"] - if outcome == "PASS": - mode = c.vtc["release"] - if mode == "on-verification": - c.state = "RELEASING" - self._settle_pass(c) + if c.state == "DELIVERED": + if outcome == "PASS": + self._release_on_pass(c) else: + c.state = "DISPUTED" + self._apply_waterfall(c, challengers=[]) + else: # DISPUTED: this Verdict resolves the open Challenge(s) + c.disputed_at = None + if outcome == "FAIL": + c.pools.note("Challenge upheld: this Verdict supersedes the PASS") + self._apply_waterfall(c, challengers=list(c.challenges)) + else: + c.pools.note("Challenge rejected: the PASS stands; window resumes") c.state = "RELEASING" - c.window_opened_at = self.now() - else: - c.state = "DISPUTED" - self._apply_waterfall(c, challenger=c.buyer, costs=0) - body = dict(verdict) - body["state"] = c.state - self._remember("verdict", verdict, body) - return 201, body - - def _settle_pass(self, c: Contract) -> None: - c.pools.paid_to_seller += c.pools.escrow - c.pools.note(f"released escrow {pc.money(c.pools.escrow)} to seller on PASS") - c.pools.escrow = 0 - self._return_bond(c) - c.state = "FINAL" - self._attest(c, outcome="performed") - - # -- Open challenge, Section 7.4 -------------------------------------- + self._close_window_if_due(c) + self._remember("verdict", verdict, c.id) + return 201, self._with_state(c, verdict) + + def _check_verdict_signer(self, c: Contract, kids: list[str]) -> None: + # Draft line 1852: where the contract names parties.verifier the Verdict + # MUST be signed by that party; otherwise 9.1 is evaluated against the + # signer. Section 3: never the Facilitator. Section 7.5: never a + # Challenger judging its own Challenge. + named = c.vtc["parties"].get("verifier") + if named and not any(pc.kid_covers(k, named) for k in kids): + raise Refuse("verifier-not-independent", + "the contract names a verifier and the Verdict is not signed " + "by that party", signer=kids[0], required_verifier=named) + for kid in kids: + if pc.kid_covers(kid, c.seller): + raise Refuse("verifier-not-independent", + "the Verdict is signed by the contract's Seller", + signer=kid, seller=c.seller) + if pc.kid_covers(kid, c.buyer): + raise Refuse("verifier-not-independent", + "the Verdict is signed by the contract's Buyer", + signer=kid, buyer=c.buyer) + if pc.kid_covers(kid, self.identity): + raise Refuse("facilitator-cannot-verify", + "a Facilitator MUST NOT act as Verifier for a contract it " + "settles", signer=kid, facilitator=self.identity) + for ch in c.challenges: + for ck in pc.signer_kids(ch): + if c.state == "DISPUTED" and pc.same_party(ck, kid): + raise Refuse("verifier-not-independent", + "a Challenger's own assertion is not a Verdict " + "(Section 7.5)", signer=kid) + + def _release_on_pass(self, c: Contract) -> None: + # on-verification: the price releases on PASS, the window opens now, + # and the Bond and fund stay locked until it closes (draft lines + # 477 to 482). An earlier version returned the Bond here and reached + # FINAL in the same call, so no window ever opened and the Figure 6 + # path was unreachable in the only mode the draft requires. + p = c.pools + # Section 7.1: cumulative release before a Verdict never exceeds the + # Bond. Under on-verification nothing is released before the Verdict, + # so E is zero here by construction and the cap cannot bind. + p.paid_to_seller += p.escrow + p.released += p.escrow + p.note(f"released escrow {pc.money(p.escrow)} to seller on PASS; challenge " + f"window of {c.window_seconds}s opens") + p.escrow = 0 + c.state = "RELEASING" + c.window_opened_at = self.now() + + # -- Open challenge, Section 7.5 -------------------------------------- def open_challenge(self, ch: dict) -> tuple[int, dict]: with self.lock: - existing = self._idempotent("challenge", ch) - if existing is not None: - return 200, existing - - c = self._contract_for(ch) - if c.state not in ("RELEASING", "DELIVERED"): - raise Refuse("wrong-state", - f"contract {c.id} is {c.state}, no challenge window " - f"is open") - window = int(c.vtc.get("challenge", {}).get("window_seconds", 0)) - if c.window_opened_at is not None and \ - self.now() - c.window_opened_at > window: + hit = self._replay("challenge", ch) + if hit is not None: + return hit + self.schemas.check(ch, "challenge.schema.json") + c = self._contract(ch["vtc_id"]) + self._tick(c) + recorded = self._delivery_digest(c) + if ch["delivery_hash"] != recorded: + raise Refuse("object-conflict", + "delivery_hash does not commit to the recorded Delivery", + expected=recorded, received=ch["delivery_hash"]) + if c.state not in ("RELEASING", "DISPUTED"): raise Refuse("challenge-window-closed", - f"the {window}s challenge window has closed") - - ok, why = pc.verify_object(ch, self.resolver, - "application/pact-challenge+json", []) + f"no challenge window is open: contract {c.id} is {c.state}, " + f"and under on-verification the window opens when a PASS " + f"is recorded and closes {c.window_seconds}s later", + state=c.state) + if c.window_opened_at is None or \ + self.now() - c.window_opened_at >= c.window_seconds: + raise Refuse("challenge-window-closed", + f"the {c.window_seconds}s challenge window has closed") + + # Section 7.5: a proof that does not conform to the profile is + # refused. Under the acceptance profile that means the committed + # instrument and a results digest. + proof = ch["proof"] + ver = c.vtc["verification"] + if proof.get("profile") != ver["profile"]: + raise Refuse("proof-nonconformant", + f"proof profile {proof.get('profile')!r} is not the " + f"contract's {ver['profile']!r}") + if proof.get("instrument_hash") != ver["criteria_hash"]: + raise Refuse("proof-nonconformant", + "the proof does not commit to the instrument the contract " + "committed to") + if "results_hash" not in proof: + raise Refuse("proof-nonconformant", + "the acceptance profile requires results_hash in the proof") + + ok, why = pc.verify_object(ch, self.resolver, MEDIA_CHALLENGE, []) if not ok: - raise Refuse("signature-invalid", why) + raise Refuse(_sig_kind(why), why) + kids = pc.signer_kids(ch) + if any(pc.kid_covers(k, c.seller) for k in kids): + raise Refuse("unexpected-signer", "a Seller cannot challenge its own " + "Delivery", signer=kids[0]) # A Challenge is a fraud proof submitted for evaluation, not a - # finding. Section 7.5: "A Challenge that is accepted is evaluated - # by a party satisfying Section 9.1, whose finding is a Verdict; the - # Challenger's own assertion is not." An earlier version of this - # file applied the waterfall directly here, which let any party with - # a resolvable key destroy a Seller's bond with no Verdict ever - # recorded. Accepting a Challenge moves the contract to DISPUTED and - # nothing else. - c.challenge = ch - c.state = "DISPUTED" - c.pools.note(f"challenge accepted from " - f"{pc.signer_kids(ch)[0] if pc.signer_kids(ch) else 'unknown'}, " - f"awaiting a Verdict from an independent evaluator") - body = dict(ch) - body["state"] = c.state - self._remember("challenge", ch, body) - return 202, body + # finding. It moves the contract to DISPUTED and nothing else; the + # Verdict that resolves it comes from an independent party. + c.challenges.append(ch) + if c.state != "DISPUTED": + c.disputed_at = self.now() + c.state = "DISPUTED" + c.pools.note(f"challenge accepted from {kids[0]}; awaiting a Verdict from " + f"an independent evaluator ({len(c.challenges)} open)") + self._record_keys(c, ch) + self._remember("challenge", ch, c.id) + return 202, self._with_state(c, ch) # -- Section 7.4, the five ranks in order ------------------------------ - def _apply_waterfall(self, c: Contract, challenger: str, costs: int) -> None: + def _apply_waterfall(self, c: Contract, challengers: list[dict]) -> None: p = c.pools + moved_from_seller = 0 # 1. Reverse any unreleased escrow to the Buyer. if p.escrow: @@ -483,55 +817,57 @@ def _apply_waterfall(self, c: Contract, challenger: str, costs: int) -> None: p.note(f"rank 1: reversed unreleased escrow {pc.money(p.escrow)} to buyer") p.escrow = 0 - # 2. Reimburse the Challenger's documented costs FROM THE VERIFICATION - # FUND. Paying this from the Bond is what made the -00 rule - # unsatisfiable, since proving fraud costs about what the work cost. - if costs: - paid = min(costs, p.fund) - p.fund -= paid - p.paid_to_challenger += paid - p.note(f"rank 2: reimbursed challenger {pc.money(paid)} from the " - f"verification fund") - if paid < costs: - p.note(f"rank 2: verification fund short by " - f"{pc.money(costs - paid)}, which is a sizing failure " - f"and not a protocol one") - - # 3. Restore the Buyer from the Bond, up to restitution_basis. - basis = c.vtc["liability"]["restitution_basis"] - cap = pc.cents(c.vtc["liability"]["cap"]) - owed = p.released if basis == "released" else pc.cents(c.vtc["price"]["amount"]) - owed = min(owed, cap) - restitution = min(owed, p.bond) + # 2. Reimburse the successful Challenger's documented costs FROM THE + # VERIFICATION FUND. The Challenge object has no member in which to + # document them (a -02 item), so this is 0.00 (CHOICES C4). + if challengers: + p.note("rank 2: the Challenge carries no cost claim; reimbursed 0.00 from " + "the verification fund (C4)") + + # 3. Restore the Buyer from the Bond, up to restitution_basis, net of + # what rank 1 already returned (CHOICES C2), and never beyond cap. + owed = self._basis_owed(c) + restitution = min(owed, p.bond, p.cap - moved_from_seller) if restitution: p.bond -= restitution p.paid_to_buyer += restitution p.restituted += restitution - p.note(f"rank 3: restitution basis {basis!r} owed {pc.money(owed)}, " - f"paid {pc.money(restitution)} from bond") - - # 4. Pay the Challenger bounty from the remaining Bond. - bounty = min(p.bond, owed) if p.bond else 0 - if bounty: - p.bond -= bounty - p.paid_to_challenger += bounty - p.note(f"rank 4: bounty {pc.money(bounty)} from the remaining bond") + moved_from_seller += restitution + p.note(f"rank 3: restitution basis {c.vtc['liability']['restitution_basis']!r}, " + f"buyer's loss {pc.money(owed)}, paid {pc.money(restitution)} from bond") + + # 4. The Challenger bounty from the remaining Bond: only to a + # Challenger that exists, the whole remainder, split equally among + # successful Challengers (CHOICES C3). + if challengers and p.bond: + pool = min(p.bond, p.cap - moved_from_seller) + share = pool // len(challengers) + paid = share * len(challengers) + p.bond -= paid + p.paid_to_challenger += paid + moved_from_seller += paid + p.note(f"rank 4: bounty {pc.money(paid)} from the remaining bond to " + f"{len(challengers)} challenger(s), {pc.money(share)} each (C3)") + elif not challengers: + p.note("rank 4: no Challenger, no bounty") - # 5. Direct any remainder per liability.remainder_to. + # 5. Direct any remainder per liability.remainder_to, within cap. remainder_to = c.vtc["liability"].get("remainder_to", "sink") if p.bond: - if remainder_to == "buyer": - p.paid_to_buyer += p.bond - else: - p.remainder += p.bond - p.note(f"rank 5: remainder {pc.money(p.bond)} directed to {remainder_to}") - p.bond = 0 - - if p.fund: - p.note(f"returned unspent verification fund {pc.money(p.fund)}") - p.fund_returned += p.fund - p.fund = 0 - + movable = min(p.bond, p.cap - moved_from_seller) + if movable: + if remainder_to == "buyer": + p.paid_to_buyer += movable + else: + p.remainder += movable + p.bond -= movable + moved_from_seller += movable + p.note(f"rank 5: remainder {pc.money(movable)} directed to {remainder_to}") + if p.bond: + p.note(f"liability.cap reached: {pc.money(p.bond)} of the bond is not " + f"the Facilitator's to move and is returned") + + self._return_bond(c, "Section 7.6, SETTLED") c.state = "SETTLED" self._attest(c, outcome="slashed") @@ -544,67 +880,69 @@ def _attest(self, c: Contract, outcome: str) -> None: recording a negative outcome. The Seller does not consent to this record and its consent is not required. """ + p = c.pools att = { "pact": "0.1", "type": "WorkAttestation", "vtc_id": c.id, "vtc_hash": c.digest(), - "parties": {"buyer": c.buyer, "seller": c.seller, - "facilitator": self.identity}, + "parties": {"buyer": c.buyer, "seller": c.seller, "facilitator": self.identity}, "subject": c.seller, "role": "seller", "outcome": outcome, "amounts": { - "settled": pc.money(c.pools.paid_to_seller), + "settled": pc.money(p.paid_to_seller), # Restitution is what the Buyer recovered FROM THE BOND. Escrow - # coming back is the Buyer's own money and is not restitution; - # conflating the two is how the -00 was able to look solvent. - "restituted": pc.money(c.pools.restituted), - "slashed": pc.money(c.pools.bond_initial - c.pools.bond_returned - - c.pools.bond), + # coming back is the Buyer's own money and is not restitution. + "restituted": pc.money(p.restituted), + "slashed": pc.money(p.bond_initial - p.bond_returned - p.bond), "currency": c.vtc["price"]["currency"], }, - "opened_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", - time.gmtime(c.created_at)), - "settled_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", - time.gmtime(self.now())), + "opened_at": iso(c.created_at), + "settled_at": iso(self.now()), } if c.delivery is not None: att["work_hash"] = c.delivery["work_hash"] - att["signatures"] = [pc.sign(att, self.key, - "application/pact-attestation+json")] + att["signatures"] = [pc.sign(att, self.key, MEDIA_ATTESTATION)] + self.schemas.check(att, "attestation.schema.json") c.attestation = att def get_attestation(self, vid: str) -> tuple[int, dict]: with self.lock: - c = self.contracts.get(vid) - if c is None: - raise Refuse("unknown-contract", f"no contract {vid}") - self._expire_if_due(c) + c = self._contract(vid) + self._tick(c) if c.attestation is None: raise Refuse("wrong-state", - f"contract {vid} is {c.state} and not terminal, so " - f"no attestation exists yet") + f"contract {vid} is {c.state} and not terminal, so no " + f"attestation exists yet") return 200, c.attestation + # -- Section 8 ----------------------------------------------------------- def capability_document(self) -> dict: - return { + doc = { "pact": "0.1", "facilitator": self.identity, + "settlement_bindings": self.settlement_bindings, + "release_modes": self.release_modes, + "verification_profiles": self.verification_profiles, + "assurance_modes": self.assurance_modes, + "max_contract_value": self.max_contract_value, "endpoints": { - "contract": "/pact/v1/contracts", - "delivery": "/pact/v1/deliveries", - "verdict": "/pact/v1/verdicts", - "challenge": "/pact/v1/challenges", - "attestation": "/pact/v1/attestations", + "contract": self.base_url + "/pact/v1/contracts", + "delivery": self.base_url + "/pact/v1/deliveries", + "verdict": self.base_url + "/pact/v1/verdicts", + "challenge": self.base_url + "/pact/v1/challenges", + "attestation": self.base_url + "/pact/v1/attestations", }, - "release_modes": ["on-verification"], - "assurance_modes": ["certain"], - "profiles": ["acceptance"], - "max_contract_value": "50000.00", - "currencies": ["USDC"], - "challenge_deposit": "0.00", + # no challenge_deposit member: absent means none is required } + doc["signature"] = pc.sign(doc, self.key, MEDIA_FACILITATOR) + self.schemas.check(doc, "facilitator.schema.json") + return doc + + +def _sig_kind(why: str) -> str: + return "algorithm-not-permitted" if why.startswith("algorithm") else "signature-invalid" # -------------------------------------------------------------------------- @@ -619,16 +957,16 @@ def capability_document(self) -> dict: } MEDIA = { - "propose": "application/pact-contract+json", - "submit_delivery": "application/pact-delivery+json", - "record_verdict": "application/pact-verdict+json", - "open_challenge": "application/pact-challenge+json", + "propose": MEDIA_CONTRACT, + "submit_delivery": MEDIA_DELIVERY, + "record_verdict": MEDIA_VERDICT, + "open_challenge": MEDIA_CHALLENGE, } class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" - server_version = "pact-reference-facilitator/0.1" + server_version = "pact-reference-facilitator/0.2" @property def fac(self) -> Facilitator: @@ -654,17 +992,26 @@ def _problem(self, exc: Refuse) -> None: "title": exc.kind.replace("-", " "), "status": status, "detail": exc.detail, - # Section 12.3: errors MUST name the rule that was violated. - "section": section, + "section": section.split(" ", 1)[1], # draft Figure 12: "7.2", not "Section 7.2" } body.update(exc.extra) self._send(status, body, "application/problem+json") - def do_GET(self) -> None: + def _guard(self, fn) -> None: try: + fn() + except Refuse as exc: + self._problem(exc) + except Exception as exc: # pragma: no cover + # Never a 409 dressed as a rule, and never the traceback: a caller + # cannot locate a Python exception in the specification. + self._problem(Refuse("internal-error", + f"the Facilitator failed internally ({type(exc).__name__})")) + + def do_GET(self) -> None: + def run() -> None: if self.path == "/.well-known/pact-facilitator": - self._send(200, self.fac.capability_document(), - "application/pact-facilitator+json") + self._send(200, self.fac.capability_document(), MEDIA_FACILITATOR) return m = re.match(r"^/pact/v1/(contracts|attestations)/([^/]+)$", self.path) if not m: @@ -672,30 +1019,30 @@ def do_GET(self) -> None: kind, vid = m.groups() if kind == "contracts": status, body = self.fac.get_contract(vid) - self._send(status, body, "application/pact-contract+json") + self._send(status, body, MEDIA_CONTRACT) else: status, body = self.fac.get_attestation(vid) - self._send(status, body, "application/pact-attestation+json") - except Refuse as exc: - self._problem(exc) - except Exception as exc: # pragma: no cover - self._problem(Refuse("wrong-state", f"{type(exc).__name__}: {exc}")) + self._send(status, body, MEDIA_ATTESTATION) + self._guard(run) def do_POST(self) -> None: - try: - m = re.match(r"^/pact/v1/(contracts|deliveries|verdicts|challenges)$", - self.path) + def run() -> None: + m = re.match(r"^/pact/v1/(contracts|deliveries|verdicts|challenges)$", self.path) if not m: raise Refuse("unknown-contract", f"no route for {self.path}") op = ROUTES[m.group(1)] length = int(self.headers.get("Content-Length", 0)) - obj = json.loads(self.rfile.read(length) or b"{}") + if length > MAX_BODY: + raise Refuse("payload-too-large", f"body exceeds {MAX_BODY} bytes") + try: + obj = json.loads(self.rfile.read(length) or b"{}") + except ValueError: + raise Refuse("schema-invalid", "body is not JSON") + if not isinstance(obj, dict): + raise Refuse("schema-invalid", "body is not a JSON object") status, body = getattr(self.fac, op)(obj) self._send(status, body, MEDIA[op]) - except Refuse as exc: - self._problem(exc) - except Exception as exc: # pragma: no cover - self._problem(Refuse("wrong-state", f"{type(exc).__name__}: {exc}")) + self._guard(run) def serve(facilitator: Facilitator, port: int = 8402, @@ -706,10 +1053,11 @@ def serve(facilitator: Facilitator, port: int = 8402, return httpd -def build(identity: str = "did:web:settle.example") -> tuple[Facilitator, pc.KeyResolver]: +def build(identity: str = "did:web:settle.example", now: Any = time.time, + base_url: str = "http://127.0.0.1:8402") -> tuple[Facilitator, pc.KeyResolver]: resolver = pc.KeyResolver() key = resolver.register(pc.Key.generate(identity + "#key-1")) - return Facilitator(identity, key, resolver), resolver + return Facilitator(identity, key, resolver, now=now, base_url=base_url), resolver def main() -> None: @@ -717,16 +1065,17 @@ def main() -> None: ap.add_argument("--port", type=int, default=8402) ap.add_argument("--verbose", action="store_true") ap.add_argument("--rules", action="store_true", - help="print the rules this implementation enforces and exit") + help="print the rules this implementation enforces, and its choices, and exit") args = ap.parse_args() if args.rules: print(RULES.strip()) + print() + print(CHOICES.strip()) return - fac, _ = build() + fac, _ = build(base_url=f"http://127.0.0.1:{args.port}") httpd = serve(fac, args.port, args.verbose) print(f"reference Facilitator {fac.identity} on http://127.0.0.1:{args.port}") - print(f"capability document at " - f"http://127.0.0.1:{args.port}/.well-known/pact-facilitator") + print(f"capability document at http://127.0.0.1:{args.port}/.well-known/pact-facilitator") httpd.serve_forever() diff --git a/tools/measure.py b/tools/measure.py index f6f90f4..c07c1c9 100644 --- a/tools/measure.py +++ b/tools/measure.py @@ -1,14 +1,19 @@ """Drive the reference pair through every terminal state and report what it costs. -Three contracts, because FINAL, SETTLED and ABANDONED are mutually exclusive -branches of Figure 2 and one contract cannot reach all three. Each is minted -fresh with real Ed25519 keys, validated against the published schemas, settled -over HTTP against tools/facilitator.py, and its attestation verified by the -party that receives it. +Four contracts, because FINAL, SETTLED and ABANDONED are mutually exclusive +branches of Figure 2 and one contract cannot reach all three, and because the +Figure 6 path (a Challenge overturns a PASS) is the one that exercises the +restitution basis. Each is minted fresh with real Ed25519 keys, validated +against the published schemas, settled over HTTP against tools/facilitator.py, +and its attestation verified by the party that receives it. -Then a set of refusals, because a settlement service is defined as much by what -it refuses as by what it accepts, and then microbenchmarks for the operations -that scale with traffic. +Time is the Facilitator's clock (draft line 480), and here that clock is a +counter the harness advances, so the challenge window and the deadline are +exercised deterministically rather than waited for. + +Then the refusals, because a settlement service is defined as much by what it +refuses as by what it accepts, each one driving the rule it is named for, and +then microbenchmarks for the operations that scale with traffic. python3 tools/measure.py # human readable python3 tools/measure.py --json # machine readable @@ -40,8 +45,11 @@ SELLER = "did:web:dataforge.example:agents:etl-3" VERIFIER = "did:web:audit.example" FACILITATOR = "did:web:settle.example" +WINDOW = 3600 +DISPUTE = 86400 -REPORT: dict = {"scenarios": {}, "refusals": {}, "micro": {}, "environment": {}} +REPORT: dict = {"scenarios": {}, "refusals": {}, "acceptances": {}, "micro": {}, + "capability_document": {}, "environment": {}} def _cpu_name() -> str: @@ -56,31 +64,46 @@ def _cpu_name() -> str: return platform.processor() or platform.machine() +_SCHEMAS = None + + def _schema_check(obj: dict, schema_file: str) -> str: - """Validate a minted object against the published schema, if jsonschema is here.""" + """Validate a minted object against the published schema. Loaded once, and + never inside a timed region.""" + global _SCHEMAS + if _SCHEMAS is None: + _SCHEMAS = fac_mod.Schemas() try: - from jsonschema import Draft202012Validator - from referencing import Registry, Resource - except ImportError: # pragma: no cover - return "skipped, jsonschema not installed" - schemas = {} - for p in (ROOT / "schemas").glob("*.schema.json"): - schemas[p.name] = json.loads(p.read_text()) - registry = Registry().with_resources( - [(name, Resource.from_contents(s)) for name, s in schemas.items()]) - v = Draft202012Validator(schemas[schema_file], registry=registry) - errors = sorted(v.iter_errors(obj), key=lambda e: e.path) - return "valid" if not errors else f"INVALID: {errors[0].message}" + _SCHEMAS.check(obj, schema_file) + except fac_mod.Refuse as exc: + return f"INVALID: {exc.detail}" + return "valid" + + +class Clock: + """The Facilitator's clock, advanced by the harness.""" + def __init__(self) -> None: + self.t = time.time() + + def __call__(self) -> float: + return self.t + + def advance(self, seconds: float) -> None: + self.t += seconds + + def at(self, seconds_ahead: float) -> str: + return fac_mod.iso(self.t + seconds_ahead) class Harness: def __init__(self, port: int) -> None: - self.fac, self.resolver = fac_mod.build(FACILITATOR) + self.clock = Clock() + base = f"http://127.0.0.1:{port}" + self.fac, self.resolver = fac_mod.build(FACILITATOR, now=self.clock, base_url=base) self.httpd = fac_mod.serve(self.fac, port) self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.thread.start() - self.base = f"http://127.0.0.1:{port}" - self.client = agents.Client(self.base) + self.client = agents.Client(base) self.buyer = agents.make_party(BUYER, self.resolver, self.client) self.seller = agents.make_party(SELLER, self.resolver, self.client) self.verifier = agents.make_party(VERIFIER, self.resolver, self.client) @@ -89,21 +112,29 @@ def stop(self) -> None: self.httpd.shutdown() self.httpd.server_close() - def fresh(self, vid: str, **kw) -> dict: - vtc = agents.draft_contract(vid, BUYER, SELLER, FACILITATOR, VERIFIER, **kw) + def fresh(self, vid: str, verifier: str | None = VERIFIER, **kw) -> dict: + kw.setdefault("deadline", self.clock.at(7 * 86400)) + vtc = agents.draft_contract(vid, BUYER, SELLER, FACILITATOR, verifier, **kw) return agents.cosign(vtc, self.buyer, self.seller) def conservation(c) -> dict: - """Every cent that went in must be accounted for at a terminal state.""" + """Every cent that went in is somewhere it can be named, at a terminal state. + + This is a ledger identity over the Facilitator's own pools, which is what + an in-memory implementation can check. It is asserted, not merely + computed: a scenario whose money does not balance fails the run. + """ p = c.pools put_in = p.bond_initial + pc.cents(c.vtc["price"]["amount"]) + \ pc.cents(c.vtc["liability"]["verification_fund"]) accounted = (p.paid_to_buyer + p.paid_to_seller + p.paid_to_challenger + p.remainder + p.bond_returned + p.fund_returned + p.escrow + p.bond + p.fund) - return {"in": pc.money(put_in), "accounted": pc.money(accounted), - "balanced": put_in == accounted} + out = {"in": pc.money(put_in), "accounted": pc.money(accounted), + "balanced": put_in == accounted} + assert out["balanced"], f"money does not balance for {c.id}: {out}" + return out def scenario(h: Harness, name: str, run) -> dict: @@ -123,106 +154,123 @@ def scenario(h: Harness, name: str, run) -> dict: "req": w.request_bytes, "resp": w.response_bytes, "ms": round(w.seconds * 1000, 2)} for w in wire], } - out.update({k: v for k, v in detail.items() if k not in ("state", "contract")}) - if "contract" in detail: - out["money"] = conservation(detail["contract"]) + c = detail.pop("contract") + att = c.attestation + ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) + out["attestation_verifies"] = ok + out["attestation_reason"] = why + out["amounts"] = att["amounts"] + out["ledger"] = c.pools.ledger + out["money"] = conservation(c) + out["schema"] = {k: _schema_check(v, f) for k, (v, f) in detail.pop("objects", {}).items()} + out.update({k: v for k, v in detail.items() if k != "state"}) REPORT["scenarios"][name] = out return out # -------------------------------------------------------------------------- -# The three terminal states +# The terminal states # -------------------------------------------------------------------------- def run_final(h: Harness) -> dict: + """PASS, window closes with no Challenge, FINAL. Four exchanges.""" vtc = h.fresh("vtc_final_01") - schema = _schema_check(vtc, "vtc.schema.json") st, _ = h.client.propose(vtc) assert st == 201, st - dlv = agents.make_delivery(vtc, h.seller, b"the delivered bytes", b"results") - dschema = _schema_check(dlv, "delivery.schema.json") st, _ = h.client.deliver(dlv) assert st == 202, st - vd = agents.make_verdict(vtc, dlv, h.verifier, "PASS") - vschema = _schema_check(vd, "verdict.schema.json") - st, _ = h.client.verdict(vd) - assert st == 201, st - - st, att = h.client.attestation(vtc["id"]) + st, body = h.client.verdict(vd) + assert st == 201 and body["state"] == "RELEASING", (st, body.get("state")) + h.clock.advance(WINDOW + 1) # the window closes on the Facilitator's clock + st, att = h.client.attestation(vtc["id"]) # the GET is what notices it assert st == 200, att - ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) c = h.fac.contracts[vtc["id"]] - return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, - "schema": {"contract": schema, "delivery": dschema, "verdict": vschema, - "attestation": _schema_check(att, "attestation.schema.json")}, - "amounts": att["amounts"], "ledger": c.pools.ledger, "contract": c} + return {"state": c.state, "contract": c, + "objects": {"contract": (vtc, "vtc.schema.json"), + "delivery": (dlv, "delivery.schema.json"), + "verdict": (vd, "verdict.schema.json"), + "attestation": (att, "attestation.schema.json")}} def run_settled(h: Harness) -> dict: + """The verifier records FAIL: DISPUTED, remedy, SETTLED. Four exchanges.""" vtc = h.fresh("vtc_settled_01") - st, _ = h.client.propose(vtc) - assert st == 201, st + h.client.propose(vtc) dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad results") - st, _ = h.client.deliver(dlv) - assert st == 202, st - vd = agents.make_verdict(vtc, dlv, h.verifier, "FAIL") - st, _ = h.client.verdict(vd) + h.client.deliver(dlv) + st, body = h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + assert st == 201 and body["state"] == "SETTLED", (st, body.get("state")) + st, att = h.client.attestation(vtc["id"]) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "contract": c, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "seller_received": pc.money(c.pools.paid_to_seller)} + + +def run_abandoned(h: Harness) -> dict: + """Signed, funded, never delivered: the deadline passes. Three exchanges.""" + vtc = h.fresh("vtc_abandoned_01", deadline=h.clock.at(3600)) + st, _ = h.client.propose(vtc) assert st == 201, st + h.clock.advance(3601) + st, body = h.client.contract(vtc["id"]) # the GET is what notices the expiry + assert st == 200 and body["state"] == "ABANDONED", (st, body.get("state")) st, att = h.client.attestation(vtc["id"]) - ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) c = h.fac.contracts[vtc["id"]] - return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, - "amounts": att["amounts"], "ledger": c.pools.ledger, + return {"state": c.state, "contract": c, "buyer_recovered": pc.money(c.pools.paid_to_buyer), - "seller_received": pc.money(c.pools.paid_to_seller), "contract": c} + "bond_returned_to_seller": pc.money(c.pools.bond_returned)} -def run_settled_price(h: Harness) -> dict: - """The same fraud, with restitution_basis "price" instead of "released". - - This is the contrast that matters. Under "released" the amount owed to the - Buyer is what was paid out before the Verdict, which under the default - on-verification release is nothing, so the Bond passes the Buyer entirely - and lands on the remainder. Under "price" the Buyer is restored from the - Bond up to the cap. Same protocol, same fraud, opposite outcome for the - defrauded party, chosen by one enum in the contract. - """ - vtc = h.fresh("vtc_settled_price", restitution_basis="price") +def run_overturned(h: Harness) -> dict: + """Figure 6: PASS, the price releases, the Buyer challenges inside the + window, an independent Verdict upholds the Challenge, the waterfall runs + with the price already gone. Five exchanges. This is the path where the + restitution basis does any work.""" + vtc = h.fresh("vtc_overturned_01") h.client.propose(vtc) - dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad") + dlv = agents.make_delivery(vtc, h.seller, b"looked fine at first", b"results") h.client.deliver(dlv) - h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + st, body = h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "PASS")) + assert st == 201 and body["state"] == "RELEASING", (st, body.get("state")) + h.clock.advance(600) + ch = agents.make_challenge(vtc, dlv, h.buyer, ["row_count_min"]) + st, body = h.client.challenge(ch) + assert st == 202 and body["state"] == "DISPUTED", (st, body) + st, body = h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + assert st == 201 and body["state"] == "SETTLED", (st, body) st, att = h.client.attestation(vtc["id"]) c = h.fac.contracts[vtc["id"]] - return {"state": c.state, "attestation_verifies": - agents.check_attestation(att, h.resolver, FACILITATOR)[0], - "attestation_reason": "ok", - "amounts": att["amounts"], "ledger": c.pools.ledger, - "buyer_recovered": pc.money(c.pools.paid_to_buyer), - "from_bond": pc.money(c.pools.paid_to_buyer - pc.cents(vtc["price"]["amount"])), - "contract": c} + return {"state": c.state, "contract": c, + "released_before_failure": pc.money(c.pools.released), + "buyer_recovered_from_bond": pc.money(c.pools.restituted), + "challenger_bounty": pc.money(c.pools.paid_to_challenger), + "objects": {"challenge": (ch, "challenge.schema.json")}} -def run_abandoned(h: Harness) -> dict: - # A deadline already past: the Seller signed, took the bond obligation, and - # never delivered. Section 6 calls this ABANDONED. - vtc = h.fresh("vtc_abandoned_01", deadline="2026-01-01T00:00:00Z") - st, _ = h.client.propose(vtc) - assert st == 201, st - st, body = h.client.contract(vtc["id"]) # the GET is what notices the expiry - st, att = h.client.attestation(vtc["id"]) - ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) +def run_settled_price(h: Harness) -> dict: + """The pre-release FAIL again, with restitution_basis "price". Under the + net-of-loss reading (facilitator.py CHOICES C2) the Buyer's loss is zero + after rank 1 either way, so the basis changes nothing here; it only + matters once value has been released, which is run_overturned.""" + vtc = h.fresh("vtc_settled_price", restitution_basis="price") + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad") + h.client.deliver(dlv) + h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + h.client.attestation(vtc["id"]) c = h.fac.contracts[vtc["id"]] - return {"state": c.state, "attestation_verifies": ok, "attestation_reason": why, - "amounts": att["amounts"], "ledger": c.pools.ledger, + return {"state": c.state, "contract": c, "buyer_recovered": pc.money(c.pools.paid_to_buyer), - "bond_kept_by_seller": pc.money(c.pools.bond_returned), "contract": c} + "from_bond": pc.money(c.pools.restituted)} # -------------------------------------------------------------------------- -# Refusals. What a settlement service will not do. +# Refusals. What a settlement service will not do, each driving the rule it +# is named for. Two acceptances are reported separately because they are +# acceptances, not refusals, and counting them as refusals was a lie. # -------------------------------------------------------------------------- def run_refusals(h: Harness) -> None: @@ -234,104 +282,186 @@ def record(name: str, status: int, body: dict) -> None: "detail": body.get("detail", "")[:200], } - # Section 7.2, before funds lock. A ten percent bond against a declared - # detection rate of 0.90 needs 20.00, so 18.00 is refused. These are the - # draft's own worked figures and its own error body, Section 12.3. - vtc = h.fresh("vtc_refuse_bond", q_min=0.90) - record("bond_below_constraint", *h.client.propose(vtc)) - - # Section 9.1, derived not declared: the Seller signs its own Verdict. - vtc = h.fresh("vtc_refuse_selfverify") - h.client.propose(vtc) - dlv = agents.make_delivery(vtc, h.seller, b"w", b"r") - h.client.deliver(dlv) - record("verdict_signed_by_seller", - *h.client.verdict(agents.make_verdict(vtc, dlv, h.seller, "PASS"))) - - # Section 3: the Facilitator cannot verify a contract it settles. - fac_party = agents.Party(FACILITATOR, h.fac.key, h.client) - record("verdict_signed_by_facilitator", - *h.client.verdict(agents.make_verdict(vtc, dlv, fac_party, "PASS"))) - - # Section 12.4: no Verdict without a recorded Delivery. - vtc2 = h.fresh("vtc_refuse_nodelivery") - h.client.propose(vtc2) - orphan = agents.make_verdict(vtc2, dlv, h.verifier, "PASS") - record("verdict_without_delivery", *h.client.verdict(orphan)) - - # Section 12.2: the same body twice is the same resource, 200 not 409. - vtc3 = h.fresh("vtc_idempotent") - h.client.propose(vtc3) - record("resubmit_identical_contract", *h.client.propose(vtc3)) + def accept(name: str, status: int, state: str, section: str, detail: str) -> None: + REPORT["acceptances"][name] = {"status": status, "state": state, + "section": section, "detail": detail} + + def delivered(vid: str, **kw) -> tuple[dict, dict]: + vtc = h.fresh(vid, **kw) + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"w", b"r") + h.client.deliver(dlv) + return vtc, dlv + + # Section 7.2, before funds lock: 18.00 against q_min 0.90 needs 20.00. + record("bond_below_constraint", *h.client.propose(h.fresh("r_bond", q_min=0.90))) + + # Section 13.2: schema conformance at propose (a zero window). + bad = agents.draft_contract("r_schema", BUYER, SELLER, FACILITATOR, VERIFIER, + deadline=h.clock.at(86400)) + bad["challenge"]["window_seconds"] = 0 + record("schema_invalid_zero_window", *h.client.propose(agents.cosign(bad, h.buyer, h.seller))) + + # Section 13.2 / Table 9: buyer and seller the same party after normalization. + same = agents.draft_contract("r_same", BUYER, BUYER + "/", FACILITATOR, VERIFIER, + deadline=h.clock.at(86400)) + record("buyer_equals_seller", *h.client.propose(agents.cosign(same, h.buyer, h.buyer))) + + # Section 16.7: a contract naming another Facilitator is a replayable instrument. + other = agents.draft_contract("r_venue", BUYER, SELLER, "did:web:elsewhere.example", + VERIFIER, deadline=h.clock.at(86400)) + record("contract_names_other_facilitator", *h.client.propose(agents.cosign(other, h.buyer, h.seller))) + + # Section 7.3 / 8: a release mode this Facilitator does not advertise. + record("release_mode_not_advertised", + *h.client.propose(h.fresh("r_mode", release="on-window"))) + + # Section 9.1 at propose: the Seller named as its own verifier. + record("named_verifier_is_seller", + *h.client.propose(h.fresh("r_selfnamed", verifier=SELLER))) + + # Section 10.1: subcontracts are not implemented and are refused, not ignored. + child = agents.draft_contract("r_child", BUYER, SELLER, FACILITATOR, VERIFIER, + deadline=h.clock.at(86400)) + child["liability"]["parent"] = {"vtc_id": "vtc_nowhere", "vtc_hash": pc.h(b"x")} + record("subcontract_refused", *h.client.propose(agents.cosign(child, h.buyer, h.seller))) + + # Section 5: a third party's signature on a co-signed contract. + third = agents.make_party("did:web:bystander.example", h.resolver, h.client) + extra = h.fresh("r_thirdsig") + extra["signatures"].append(pc.sign({k: v for k, v in extra.items() if k != "signatures"}, + third.key, agents.MEDIA_CONTRACT)) + record("third_party_signature", *h.client.propose(extra)) + + # Section 13.1 / Table 9: alg none, with a correct typ so only the allowlist fires. + tampered = h.fresh("r_alg") + prot = pc.b64u(pc.jcs({"alg": "none", "kid": h.buyer.key.kid, + "typ": agents.MEDIA_CONTRACT})) + tampered["signatures"][0]["protected"] = prot + record("algorithm_none", *h.client.propose(tampered)) # Section 12.2: the same id with different bytes is 409. + vtc3 = h.fresh("r_idem") + h.client.propose(vtc3) altered = json.loads(json.dumps(vtc3)) altered["liability"]["seller_bond"] = "19.00" altered = agents.cosign({k: v for k, v in altered.items() if k != "signatures"}, h.buyer, h.seller) record("altered_contract_same_id", *h.client.propose(altered)) - # Section 9.1 again, at proposal: buyer and seller the same party. - same = agents.draft_contract("vtc_refuse_same", BUYER, BUYER + "/", - FACILITATOR, VERIFIER) - record("buyer_equals_seller", - *h.client.propose(agents.cosign(same, h.buyer, h.buyer))) + # Section 12: a Delivery from a look-alike of the Seller's identifier. + evil = agents.make_party(SELLER + ".evil", h.resolver, h.client) + vtc7 = h.fresh("r_prefix") + h.client.propose(vtc7) + record("delivery_by_prefix_lookalike", + *h.client.deliver(agents.make_delivery(vtc7, evil, b"w", b"r"))) - # Section 13.1: an algorithm off the allowlist. - tampered = h.fresh("vtc_refuse_alg") - prot = pc.b64u(pc.jcs({"alg": "none", "kid": h.buyer.key.kid, "typ": "x"})) - tampered["signatures"][0]["protected"] = prot - record("algorithm_none", *h.client.propose(tampered)) + # Section 6: a Delivery must carry evidence conformant to the profile. + vtc5 = h.fresh("r_noevidence") + h.client.propose(vtc5) + d5 = agents.make_delivery(vtc5, h.seller, b"w", b"r") + del d5["evidence"] + record("delivery_without_evidence", + *h.client.deliver(h.seller.sign_into(d5, agents.MEDIA_DELIVERY))) - # --- the cases the September review found this implementation failing --- + # Section 6: input_hash is REQUIRED where the tier re-executes. + vtc8 = h.fresh("r_noinput") + h.client.propose(vtc8) + d8 = agents.make_delivery(vtc8, h.seller, b"w", b"r") + del d8["input_hash"] + record("delivery_without_input_hash", + *h.client.deliver(h.seller.sign_into(d8, agents.MEDIA_DELIVERY))) + + # Section 12.4: no Verdict without a recorded Delivery. + vtc2 = h.fresh("r_nodelivery") + h.client.propose(vtc2) + _, some_dlv = delivered("r_donor") + record("verdict_without_delivery", + *h.client.verdict(agents.make_verdict(vtc2, some_dlv, h.verifier, "PASS"))) + + # Section 9.1, DERIVED: no verifier named, and the Seller signs the Verdict. + vtc_s, d_s = delivered("r_selfverify", verifier=None) + record("verdict_signed_by_seller", + *h.client.verdict(agents.make_verdict(vtc_s, d_s, h.seller, "PASS"))) + + # Section 3: no verifier named, and the Facilitator signs the Verdict. + fac_party = agents.Party(FACILITATOR, h.fac.key, h.client) + record("verdict_signed_by_facilitator", + *h.client.verdict(agents.make_verdict(vtc_s, d_s, fac_party, "PASS"))) # Draft line 1852: the contract names a verifier, so only that party judges. stranger = agents.make_party("did:web:watchdog.example", h.resolver, h.client) - vtc4 = h.fresh("vtc_refuse_stranger") - h.client.propose(vtc4) - d4 = agents.make_delivery(vtc4, h.seller, b"w", b"r") - h.client.deliver(d4) + vtc4, d4 = delivered("r_stranger") record("verdict_by_unnamed_party", *h.client.verdict(agents.make_verdict(vtc4, d4, stranger, "PASS"))) - # Section 7.5: a Challenge is evaluated by an independent party whose - # finding is a Verdict. Accepting one must NOT settle the contract. - st, _ = h.client.challenge(agents.make_challenge(vtc4, d4, stranger, ["rows"])) - c4 = h.fac.contracts["vtc_refuse_stranger"] - REPORT["refusals"]["challenge_alone_does_not_settle"] = { - "status": st, "type": f"state stays {c4.state}", - "section": "Section 7.5", - "detail": f"bond intact: {pc.money(c4.pools.bond)} of " - f"{pc.money(c4.pools.bond_initial)}, attestation issued: " - f"{c4.attestation is not None}", - } - - # Section 6: a Delivery must carry evidence conformant to the profile. - vtc5 = h.fresh("vtc_refuse_noevidence") - h.client.propose(vtc5) - d5 = agents.make_delivery(vtc5, h.seller, b"w", b"r") - del d5["evidence"] - d5 = h.seller.sign_into(d5, agents.MEDIA_DELIVERY) - record("delivery_without_evidence", *h.client.deliver(d5)) + # Section 12.4: a Verdict over a different instrument. + wrong_inst = agents.make_verdict(vtc4, d4, h.verifier, "PASS") + wrong_inst["instrument_hash"] = pc.h(b"some other instrument") + wrong_inst["signature"] = pc.sign({k: v for k, v in wrong_inst.items() if k != "signature"}, + h.verifier.key, agents.MEDIA_VERDICT) + record("verdict_over_other_instrument", *h.client.verdict(wrong_inst)) # RFC 8725 3.11 and vector V-05: typ carries the full media type. - vtc6 = h.fresh("vtc_refuse_typ") - h.client.propose(vtc6) - d6 = agents.make_delivery(vtc6, h.seller, b"w", b"r") - h.client.deliver(d6) - wrong = agents.make_verdict(vtc6, d6, h.verifier, "PASS") - wrong["signature"] = pc.sign({k: v for k, v in wrong.items() if k != "signature"}, - h.verifier.key, agents.MEDIA_DELIVERY) - record("verdict_signed_with_delivery_typ", *h.client.verdict(wrong)) - - # Section 9.1 normalization: a longer identifier is a different party. - evil = agents.make_party(SELLER + ".evil", h.resolver, h.client) - vtc7 = h.fresh("vtc_refuse_prefix") - h.client.propose(vtc7) - d7 = agents.make_delivery(vtc7, h.seller, b"w", b"r") - h.client.deliver(d7) - record("verdict_by_prefix_lookalike", - *h.client.verdict(agents.make_verdict(vtc7, d7, evil, "PASS"))) + wrong_typ = agents.make_verdict(vtc4, d4, h.verifier, "PASS") + wrong_typ["signature"] = pc.sign({k: v for k, v in wrong_typ.items() if k != "signature"}, + h.verifier.key, agents.MEDIA_DELIVERY) + record("verdict_signed_with_delivery_typ", *h.client.verdict(wrong_typ)) + + # Section 7.5: a Challenge before any window is open. + record("challenge_before_window", + *h.client.challenge(agents.make_challenge(vtc4, d4, h.buyer, ["rows"]))) + + # Now a PASS, so the window opens; then the two window-related refusals + # and the acceptance that matters most. + h.client.verdict(agents.make_verdict(vtc4, d4, h.verifier, "PASS")) + bad_proof = agents.make_challenge(vtc4, d4, h.buyer, ["rows"]) + bad_proof["proof"]["instrument_hash"] = pc.h(b"not the committed instrument") + bad_proof["signature"] = pc.sign({k: v for k, v in bad_proof.items() if k != "signature"}, + h.buyer.key, agents.MEDIA_CHALLENGE) + record("challenge_proof_nonconformant", *h.client.challenge(bad_proof)) + + st, body = h.client.challenge(agents.make_challenge(vtc4, d4, stranger, ["rows"])) + c4 = h.fac.contracts["r_stranger"] + accept("challenge_alone_does_not_settle", st, body.get("state", "?"), "7.5", + f"bond still locked: {pc.money(c4.pools.bond)} of " + f"{pc.money(c4.pools.bond_initial)}; attestation issued: {c4.attestation is not None}") + + # Section 7.5: the Challenger's own assertion is not a Verdict. + record("verdict_by_the_challenger", + *h.client.verdict(agents.make_verdict(vtc4, d4, stranger, "FAIL"))) + + # A second PASS on a fresh contract, then a Challenge after the window. + vtc9, d9 = delivered("r_late") + h.client.verdict(agents.make_verdict(vtc9, d9, h.verifier, "PASS")) + h.clock.advance(WINDOW + 1) + record("challenge_after_window", + *h.client.challenge(agents.make_challenge(vtc9, d9, h.buyer, ["rows"]))) + + # Section 12: a Verdict on a contract that is already FINAL. + record("verdict_after_final", + *h.client.verdict(agents.make_verdict(vtc9, d9, h.verifier, "FAIL"))) + + # Section 12.2: the same body twice is the same resource, 200 not 409. + st, body = h.client.propose(vtc3) + accept("resubmit_identical_contract", st, body.get("state", "?"), "12.2", + "the current resource, not a snapshot") + + +# -------------------------------------------------------------------------- +# Capability document, Section 8 +# -------------------------------------------------------------------------- + +def run_capability(h: Harness) -> None: + st, doc = h.client.capability() + ok, why = pc.verify_object(doc, h.resolver, fac_mod.MEDIA_FACILITATOR, [FACILITATOR]) + REPORT["capability_document"] = { + "status": st, + "schema": _schema_check(doc, "facilitator.schema.json"), + "signature_verifies": ok, + "release_modes": doc.get("release_modes"), + "settlement_bindings": doc.get("settlement_bindings"), + } # -------------------------------------------------------------------------- @@ -339,7 +469,6 @@ def record(name: str, status: int, body: dict) -> None: # -------------------------------------------------------------------------- def bench(fn, n: int = 2000) -> dict: - # Warm up, then take the median of per-call microseconds. for _ in range(50): fn() samples = [] @@ -357,39 +486,45 @@ def run_micro(h: Harness) -> None: dlv = agents.make_delivery(vtc, h.seller, b"w" * 4096, b"r" * 1024) key = h.buyer.key + # Labels say what is timed. "sign" and "verify" both include canonicalizing + # the contract to rebuild the detached payload, because that is what a + # Facilitator does per signature; the raw Ed25519 primitive is a fraction + # of each and is reported on its own so the reader can subtract. REPORT["micro"]["canonicalize_contract"] = bench(lambda: pc.jcs(vtc)) - REPORT["micro"]["digest_contract"] = bench( + REPORT["micro"]["canonicalize_and_digest_contract"] = bench( lambda: pc.digest_over(pc.hashable(vtc))) - REPORT["micro"]["sign_contract_ed25519"] = bench( + REPORT["micro"]["sign_contract_incl_canonicalization"] = bench( lambda: pc.sign(vtc, key, agents.MEDIA_CONTRACT), n=500) entry = pc.sign(vtc, key, agents.MEDIA_CONTRACT) - REPORT["micro"]["verify_contract_ed25519"] = bench( + REPORT["micro"]["verify_contract_signature_end_to_end"] = bench( lambda: pc.verify_entry(vtc, entry, h.resolver), n=500) + msg = b"x" * 1500 + sig = key.sign_bytes(msg) + REPORT["micro"]["ed25519_sign_primitive_1500B"] = bench(lambda: key.sign_bytes(msg), n=500) + REPORT["micro"]["ed25519_verify_primitive_1500B"] = bench( + lambda: key.verify_bytes(sig, msg), n=500) REPORT["micro"]["normalize_identifier"] = bench(lambda: pc.norm(SELLER)) - REPORT["micro"]["assurance_constraint"] = bench( - lambda: pc.assurance_holds(180.0, 18.0, 0.9091, 0.0)) + REPORT["micro"]["assurance_constraint_exact_decimal"] = bench( + lambda: pc.assurance_holds("180.00", "18.00", "0.9091", "0")) + # RFC 9162 root over N leaves, pactcore.mth. The Facilitator does not build + # trees (subcontracts are not implemented); this is the primitive a + # Section 10 implementation would call per parent attestation. for n in (2, 8, 64): leaves = [pc.jcs({"child": i}) for i in range(n)] - REPORT["micro"][f"merkle_root_{n}_children"] = bench( + REPORT["micro"][f"rfc9162_root_{n}_leaves"] = bench( lambda leaves=leaves: pc.mth(leaves), n=200 if n > 8 else 1000) REPORT["micro"]["canonical_bytes"] = { "contract": len(pc.jcs(pc.hashable(vtc))), "delivery": len(pc.jcs(pc.hashable(dlv))), } - - # There is deliberately NO T0-reexec figure here. An earlier version timed + # There is deliberately NO T0-reexec figure. An earlier version timed # examples/acceptance-harness/test_acceptance.py by running it as a plain - # script, which executes no tests at all: it is a pytest module, so it - # imports, defines its test functions, and exits 0 in silence. The number - # that produced was pytest's import time and nothing else, and the claim - # built on it, that verification cost exceeds protocol cost by three orders - # of magnitude, had no measurement behind it. Producing an honest figure - # needs the harness invoked through pytest with its documented arguments, - # and those currently fail because pytest_addoption sits in a test module - # rather than a conftest.py. Moving it changes the directory manifest and - # therefore criteria_hash, which is a -02 item. + # script, which executes no tests at all; the number was pytest's import + # time. An honest figure needs the harness invoked through pytest, which + # currently fails because pytest_addoption sits in a test module rather + # than a conftest.py, and moving it changes criteria_hash: a -02 item. # -------------------------------------------------------------------------- @@ -405,14 +540,18 @@ def main() -> None: "platform": platform.platform(), "processor": _cpu_name(), "signature_algorithm": "Ed25519 (EdDSA, RFC 8037)", - "note": "single host, loopback HTTP, in-memory store, no payment rail", + "note": "single host, loopback HTTP, in-memory store, no payment rail; the " + "Facilitator's clock is advanced by the harness", } h = Harness(args.port) try: + _schema_check({}, "vtc.schema.json") # load schemas outside any timed region + run_capability(h) scenario(h, "FINAL", lambda: run_final(h)) scenario(h, "SETTLED", lambda: run_settled(h)) scenario(h, "ABANDONED", lambda: run_abandoned(h)) + scenario(h, "OVERTURNED_PASS", lambda: run_overturned(h)) scenario(h, "SETTLED_basis_price", lambda: run_settled_price(h)) run_refusals(h) run_micro(h) @@ -427,26 +566,36 @@ def main() -> None: print(f"reference pair on {e['processor']}, Python {e['python']}, {e['signature_algorithm']}") print(f"{e['note']}\n") + cd = REPORT["capability_document"] + print(f"CAPABILITY DOCUMENT status {cd['status']}, schema {cd['schema']}, " + f"signature verifies: {cd['signature_verifies']}\n") + print("TERMINAL STATES") for name, s in REPORT["scenarios"].items(): - print(f" {name:<10} {s['messages']} messages, " - f"{s['request_bytes']}B out / {s['response_bytes']}B back, " - f"{s['wall_ms']}ms, attestation verifies: {s['attestation_verifies']}") - print(f" amounts {s['amounts']}") + print(f" {name:<20} {s['messages']} messages, {s['request_bytes']}B out / " + f"{s['response_bytes']}B back, {s['wall_ms']}ms, attestation verifies: " + f"{s['attestation_verifies']}, money balanced: {s['money']['balanced']}") + print(f" amounts {s['amounts']}") + bad = [k for k, v in s.get("schema", {}).items() if v != "valid"] + if bad: + print(f" SCHEMA FAILURES: {bad}") print() - print("REFUSALS") + print(f"REFUSALS ({len(REPORT['refusals'])})") for name, r in REPORT["refusals"].items(): - print(f" {name:<32} {r['status']} {r['type']:<36} {r['section']}") + print(f" {name:<34} {r['status']} {r['type']:<34} {r['section']}") + print(f"\nACCEPTANCES ({len(REPORT['acceptances'])})") + for name, r in REPORT["acceptances"].items(): + print(f" {name:<34} {r['status']} state {r['state']:<12} {r['section']} {r['detail']}") print() print("MICROBENCHMARKS, median microseconds per call") for name, m in REPORT["micro"].items(): if isinstance(m, dict) and "median_us" in m: - print(f" {name:<34} {m['median_us']:>10.2f} us") + print(f" {name:<40} {m['median_us']:>10.2f} us") cb = REPORT["micro"].get("canonical_bytes", {}) if cb: - print(f" canonical bytes contract {cb['contract']}, " + print(f" canonical bytes contract {cb['contract']}, " f"delivery {cb['delivery']}") print("\n no T0-reexec figure is reported; see the comment in run_micro") diff --git a/tools/pactcore.py b/tools/pactcore.py index 908def2..4419b49 100644 --- a/tools/pactcore.py +++ b/tools/pactcore.py @@ -35,6 +35,7 @@ import hashlib import json import unicodedata +from decimal import Decimal from dataclasses import dataclass, field from typing import Any, Callable @@ -201,10 +202,19 @@ def required_bond(price: float, q: float, released: float = 0.0) -> float: return price * (1.0 - q) / q + released -def assurance_holds(price: float, bond: float, q_min: float, - released: float = 0.0) -> bool: - # Compare in cents to keep the decimal figures of a contract exact. - return round(bond * 100) >= round(required_bond(price, q_min, released) * 100) +def assurance_holds(price: str | float, bond: str | float, q_min: str | float, + released: str | float = "0") -> bool: + """B >= P(1-q)/q + E, evaluated exactly. + + Multiplied through by q (which is positive) this is B*q >= P*(1-q) + E*q, + which needs no division and no rounding. An earlier version rounded both + sides to the nearest cent first, and a bond one hundredth of a cent short + of the bound passed. + """ + P, B, q, E = (Decimal(str(x)) for x in (price, bond, q_min, released)) + if q <= 0: + raise ValueError("q must be greater than zero") + return B * q >= P * (1 - q) + E * q # -------------------------------------------------------------------------- @@ -222,14 +232,18 @@ def b64u_decode(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) -def signing_input(protected: dict, obj: dict) -> bytes: +def signing_input(protected_b64: str, obj: dict) -> bytes: """ASCII(BASE64URL(UTF8(protected)) || "." || BASE64URL(JCS(object))). Exactly RFC 7515 section 5.1, over the object with its signing member removed. The payload is never transmitted; a verifier rebuilds it from - the object it holds. + the object it holds. The protected header is used AS TRANSMITTED: an + earlier version re-serialized the parsed header, which meant a + conformant JWS whose header bytes differed from this module's own + serialization (whitespace, key order) was rejected, and the commitment + depended on a re-serialization the signer never saw. """ - return (b64u(jcs(protected)) + "." + b64u(jcs(signable(obj)))).encode("ascii") + return (protected_b64 + "." + b64u(jcs(signable(obj)))).encode("ascii") @dataclass @@ -301,6 +315,16 @@ def resolve(self, kid: str) -> Key | None: return self._keys.get(kid) +def public_bytes(key: "Key") -> bytes: + """Raw public key bytes, for the Section 16.11 record of what was resolved.""" + from cryptography.hazmat.primitives import serialization + if key.alg == "EdDSA": + return key.public.public_bytes(serialization.Encoding.Raw, + serialization.PublicFormat.Raw) + return key.public.public_bytes(serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint) + + def sign(obj: dict, key: Key, typ: str) -> dict: """Return one entry for the object's `signatures` array. @@ -308,9 +332,9 @@ def sign(obj: dict, key: Key, typ: str) -> dict: an omitted typ lets an attacker present a token minted for one purpose as one minted for another. """ - protected = {"alg": key.alg, "kid": key.kid, "typ": typ} - sig = key.sign_bytes(signing_input(protected, obj)) - return {"protected": b64u(jcs(protected)), "signature": b64u(sig)} + protected_b64 = b64u(jcs({"alg": key.alg, "kid": key.kid, "typ": typ})) + sig = key.sign_bytes(signing_input(protected_b64, obj)) + return {"protected": protected_b64, "signature": b64u(sig)} def verify_entry(obj: dict, entry: dict, resolver: KeyResolver, @@ -338,8 +362,10 @@ def verify_entry(obj: dict, entry: dict, resolver: KeyResolver, alg = protected["alg"] if alg not in ALLOWED_ALGS: # Rejecting `none` and everything off the allowlist is the whole point: - # absent one, the attacker selects the algorithm. - return False, f"algorithm {alg!r} is not allowed" + # absent one, the attacker selects the algorithm. The reason string + # starts with "algorithm" so a caller can map it to Table 9's + # algorithm-not-permitted rather than a generic signature failure. + return False, f"algorithm {alg!r} is not permitted" key = resolver.resolve(protected["kid"]) if key is None: @@ -349,7 +375,7 @@ def verify_entry(obj: dict, entry: dict, resolver: KeyResolver, try: key.verify_bytes(b64u_decode(entry["signature"]), - signing_input(protected, obj)) + signing_input(entry["protected"], obj)) except Exception: return False, "signature does not verify" return True, "ok" @@ -427,7 +453,13 @@ def mth(items: list[bytes]) -> bytes: # -------------------------------------------------------------------------- def cents(amount: str | float) -> int: - return round(float(amount) * 100) + """Whole cents. Amounts with more than two decimal places are refused at + propose by the Facilitator (it settles in cents); this never rounds.""" + d = Decimal(str(amount)) + scaled = d * 100 + if scaled != scaled.to_integral_value(): + raise ValueError(f"amount {amount!r} is not a whole number of cents") + return int(scaled) def money(c: int) -> str: @@ -450,6 +482,7 @@ class Pools: bond_initial: int = 0 bond_returned: int = 0 fund_returned: int = 0 + cap: int = 0 # liability.cap: the most the Facilitator may move from the Seller released: int = 0 # E in the constraint of Section 7.2 paid_to_buyer: int = 0 restituted: int = 0 From 8ba7ac916838d218be08a955e4c7f48cc241a0d4 Mon Sep 17 00:00:00 2001 From: Laxmikant Sharma Date: Sat, 12 Sep 2026 07:49:51 -0700 Subject: [PATCH 4/4] tools/README: the validator recomputes digests but verifies no signature --- tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/README.md b/tools/README.md index 90ded32..84412c3 100644 --- a/tools/README.md +++ b/tools/README.md @@ -5,7 +5,7 @@ implementation of the protocol. | File | What it is | |---|---| -| `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. No check is cryptographic. | +| `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. It recomputes digests and Merkle roots but verifies no signature. | | `pactcore.py` | Canonicalization, digests, JWS signing and verification over the transmitted protected header, identifier normalization, the assurance constraint in exact decimal arithmetic, and the RFC 9162 Merkle tree. | | `facilitator.py` | A reference Facilitator: the six operations of Table 1 over five paths, the Figure 2 state machine including the challenge window and the Figure 6 overturned-PASS path, the Section 7.4 waterfall, schema validation of every posted object, a signed capability document, and RFC 9457 refusals in the draft's own namespace that name the rule. `--rules` prints what it enforces and what it chose. | | `agents.py` | Buyer, Seller, Verifier and Challenger clients. |