Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# tools

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`. 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. |
| `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 cryptography
python3 tools/validate.py
python3 tools/measure.py
python3 tools/facilitator.py --rules
```

`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

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 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
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.

## 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.
217 changes: 217 additions & 0 deletions tools/agents.py
Original file line number Diff line number Diff line change
@@ -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 | 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",
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."""
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": parties,
"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])
Loading
Loading