diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a09025..325170d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,40 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [3.0.0] — 2026-09-13 + +### Changed +- **BREAKING (new proofs only): `hashes.chain` is now the RFC 6962 Merkle root of one + commitment per chain field**, `SHA256(field || 0x00 || nonce || canonical_json(value))`, + with 32 fresh random bytes of nonce per field and per proof. Proofs published under + spec_version `"1.1"`, `"1.2"`, `"2.0"` and `"2.1"` keep their own algorithm: nothing is + recomputed or re-anchored retroactively. + + The reason is third-party verifiability. Up to 2.1 the chain hash was computed over the + values, and a public proof redacts `transaction_id` and `buyer_fingerprint` — so a third + party could not recompute the anchored hash, and the published procedure reported + TAMPERED against an honest issuer. A proof now publishes every commitment and no value. +- `spec_version` is no longer informational: it selects the chain hash algorithm. + +### Added +- `commitments`: one commitment per committed field, published in full. +- **Selective disclosure**: the owner opens any single field by handing over its + `(nonce, value)` pair out of band; the counterparty checks it against the published + commitment. Field name and per-field nonce are in the preimage, so a commitment cannot + be moved between fields and disclosing one field reveals nothing about a low-entropy + neighbour. +- **Section 2.2 — batch anchoring**: external anchors MAY cover the Merkle root of a batch + of proofs, each proof carrying its own inclusion proof (`batch_anchor`). Verifiers MUST + check the audit path length against the length `tree_size` requires — an overstated + `tree_size` is otherwise accepted, the walk reaching the real root and stopping early. +- **Pending state**: a proof whose batch has not closed has no external anchor. + `batch_anchor.status: "pending"` MUST be reported as waiting, never as tampering. +- Test vectors 10-12: two per-field commitment vectors with fixed nonces, one batch-anchor + vector with the root and an inclusion path per leaf. `check_consistency.py` recomputes + all three from an independent implementation of the primitives. + +--- + ## [2.1.3] — 2026-03-24 ### Added diff --git a/README.md b/README.md index 0627d79..2a779ee 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Want to add yours? Open a PR. ## Test vectors -[`test-vectors.json`](test-vectors.json) contains 9 test cases (7 legacy string-concatenation vectors + 2 canonical-JSON vectors for spec_version 1.2/2.1). Any conformant implementation MUST pass all vectors. +[`test-vectors.json`](test-vectors.json) contains 12 test cases (7 legacy string-concatenation vectors, 2 canonical-JSON vectors for spec_version 1.2/2.1, 2 per-field commitment vectors for spec_version 3.0, and 1 batch-anchor Merkle vector). Any conformant implementation MUST pass all vectors. ## Composability diff --git a/SPEC.md b/SPEC.md index 1d6f87f..08ef95c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,4 +1,4 @@ -# ArkForge Proof Specification v2.1.3 +# ArkForge Proof Specification v3.0.0 An open standard for verifiable agent-to-agent execution proofs. @@ -39,6 +39,7 @@ A conformant proof is a JSON object. The following fields are **required**: | `hashes.request` | string | SHA-256 hash of canonical JSON request. Format: `sha256:` | | `hashes.response` | string | SHA-256 hash of canonical JSON response. Format: `sha256:` | | `hashes.chain` | string | Chain hash binding all components. Format: `sha256:` | +| `commitments` | object | One commitment per committed field, `field -> sha256:` (spec_version `"3.0"`) | | `parties.buyer_fingerprint` | string | SHA-256 hash of the buyer's API key (hex) | | `parties.seller` | string | Target service domain (e.g. `arkforge.fr`) | | `payment.provider` | string | Payment provider identifier (see Payment variants) | @@ -58,6 +59,14 @@ A conformant proof is a JSON object. The following fields are **required**: "response": "sha256:", "chain": "sha256:" }, + "commitments": { + "buyer_fingerprint": "sha256:", + "request_hash": "sha256:", + "response_hash": "sha256:", + "seller": "sha256:", + "timestamp": "sha256:", + "transaction_id": "sha256:" + }, "parties": { "buyer_fingerprint": "", "seller": "example.com" @@ -77,13 +86,29 @@ A conformant proof is a JSON object. The following fields are **required**: ```json { "proof_id": "prf_20260225_170950_fdec72", - "spec_version": "2.1", + "spec_version": "3.0", "timestamp": "2026-02-25T17:09:47Z", "hashes": { "request": "sha256:", "response": "sha256:", "chain": "sha256:" }, + "commitments": { + "buyer_fingerprint": "sha256:", + "request_hash": "sha256:", + "response_hash": "sha256:", + "seller": "sha256:", + "timestamp": "sha256:", + "transaction_id": "sha256:" + }, + "batch_anchor": { + "status": "anchored", + "batch_id": "batch_20260913_132956_516", + "leaf_index": 0, + "tree_size": 4, + "audit_path": ["", ""], + "root": "sha256:" + }, "parties": { "buyer_fingerprint": "", "seller": "example.com" @@ -102,9 +127,9 @@ A conformant proof is a JSON object. The following fields are **required**: ``` **Note:** `spec_version` indicates the chain hash algorithm used: -- `"1.2"` (current): canonical JSON chain hash — see section 2 -- `"2.1"` (current + receipt): canonical JSON chain hash with `receipt_content_hash` -- `"1.1"`, `"2.0"` (legacy): string concatenation — see section 2 backward compatibility +- `"3.0"` (current): Merkle root of per-field commitments — see section 2 +- `"1.2"`, `"2.1"`: canonical JSON over the values themselves — see section 2 backward compatibility +- `"1.1"`, `"2.0"` (legacy): string concatenation — same section ### Payment variants @@ -122,7 +147,8 @@ All variants produce a valid chain hash. The `payment.transaction_id` value is u | Field | Type | Description | |-------|------|-------------| -| `spec_version` | string | Proof format version (`"1.1"` or `"2.0"`). Informational for auditors | +| `spec_version` | string | Proof format version (`"3.0"`, `"2.1"`, `"1.2"`, `"1.1"`, `"2.0"`). Selects the chain hash algorithm — **not** informational | +| `batch_anchor` | object | Inclusion proof from this chain hash up to the anchored batch root — see section 2.2 | | `upstream_timestamp` | string | Upstream service's HTTP `Date` header (RFC 7231 format). **Included in chain hash** when present | | `provider_payment` | object | External receipt verification (see section 2.1). `receipt_content_hash` **included in chain hash** when present | | `arkforge_signature` | string | Ed25519 signature of the chain hash. Format: `ed25519:` | @@ -145,55 +171,95 @@ All variants produce a valid chain hash. The `payment.transaction_id` value is u The chain hash binds every element of a transaction into a single verifiable seal. -### Algorithm (spec_version "1.2" and "2.1" — current) +### Algorithm (spec_version "3.0" — current) -The chain hash is computed by serializing all components into a canonical JSON object and hashing the result. +Each chain field is committed to separately, and the chain hash is the RFC 6962 Merkle +root of those commitments: ``` -chain_data = { - "buyer_fingerprint": , - "request_hash": , - "response_hash": , - "seller": , - "timestamp": , - "transaction_id": , - // optional fields — only include when present and non-null: - "upstream_timestamp": , // spec_version "1.2" with upstream - "receipt_content_hash": , // spec_version "2.1" — strip "sha256:" prefix -} +commitment(field) = SHA256(field_name || 0x00 || nonce || canonical_json(value)) -chain_hash = SHA256(canonical_json(chain_data)) +chain_hash = MerkleRootRFC6962([ leaf(commitment(f)) for f in sorted(fields) ]) + leaf(x) = SHA256(0x00 || x) + node(l, r) = SHA256(0x01 || l || r) ``` -Keys are sorted alphabetically (canonical JSON). Optional fields are included in the dict only when present and non-null. +- `field_name` is the UTF-8 field name, followed by a single `0x00` byte. It is in the + preimage so that a commitment cannot be moved from one field to another during a + partial disclosure. +- `nonce` is **32 fresh random bytes, drawn per field and per proof**. Per field, so + that disclosing one field does not let anyone brute-force a low-entropy neighbour + (an amount, a domain). Per proof, so that two proofs over the same value do not + produce equal commitments that link them. +- `value` is encoded with `canonical_json`, never `str()`: `100` and `"100"` must not + open the same commitment. +- Leaves are ordered by field name, which a verifier reconstructs from the published + commitments alone. There is no separate ordering to publish or to trust. + +**Why commitments?** Up to spec 2.1 the chain hash was computed over the field values +themselves. A public proof redacts `transaction_id` and `buyer_fingerprint`, so a third +party could not recompute the anchored hash at all — the published verification +procedure either skipped the check or reported TAMPERED against an honest issuer. With +per-field commitments the proof publishes every commitment and no value: the anchored +hash is recomputable from public data, and nothing that was private becomes public. + +### Fields committed + +The committed set is unchanged from spec 2.1: + +``` +request_hash, response_hash, transaction_id, timestamp, buyer_fingerprint, seller +[+ upstream_timestamp] when present and non-null +[+ receipt_content_hash] when present, stripped of its "sha256:" prefix +``` #### Reference implementation (Python) ```python -import json, hashlib +import json, hashlib, secrets -def canonical_json(data: dict) -> str: +def canonical_json(data) -> str: return json.dumps(data, sort_keys=True, separators=(",", ":")) -def sha256_hex(s: str) -> str: - return hashlib.sha256(s.encode("utf-8")).hexdigest() +def commit(field: str, nonce: bytes, value) -> bytes: + return hashlib.sha256(field.encode("utf-8") + b"\x00" + nonce + + canonical_json(value).encode("utf-8")).digest() + +def leaf(x: bytes) -> bytes: return hashlib.sha256(b"\x00" + x).digest() +def node(l: bytes, r: bytes) -> bytes: return hashlib.sha256(b"\x01" + l + r).digest() + +def merkle_root(leaves): + # RFC 6962: the odd node is promoted, never duplicated. Duplicating it (the + # Bitcoin shape, CVE-2012-2459) lets two different leaf sets share a root. + if len(leaves) == 1: + return leaves[0] + k = 1 + while k * 2 < len(leaves): + k *= 2 + return node(merkle_root(leaves[:k]), merkle_root(leaves[k:])) + +nonces = {f: secrets.token_bytes(32) for f in chain_data} +commitments = {f: commit(f, nonces[f], v).hex() for f, v in chain_data.items()} +chain_hash = merkle_root([leaf(bytes.fromhex(commitments[f])) + for f in sorted(commitments)]).hex() +``` -chain_data = { - "buyer_fingerprint": buyer_fingerprint, - "request_hash": request_hash, - "response_hash": response_hash, - "seller": seller, - "timestamp": timestamp, - "transaction_id": transaction_id, -} -if upstream_timestamp: - chain_data["upstream_timestamp"] = upstream_timestamp -if receipt_content_hash: - chain_data["receipt_content_hash"] = receipt_content_hash.removeprefix("sha256:") +### Selective disclosure -chain_hash = sha256_hex(canonical_json(chain_data)) +The proof owner holds the nonces. To prove one field to a counterparty without revealing +any other, the owner hands over that field's `(nonce, value)` pair out of band. The +counterparty recomputes `commitment(field)` and compares it with the commitment published +in the proof — which is already covered by the anchored chain hash. + +```python +recomputed = commit(field, bytes.fromhex(nonce), value).hex() +assert recomputed == proof["commitments"][field] ``` +Nothing about the undisclosed fields follows: each carries its own independent 32-byte +nonce. There is no disclosure endpoint and no signed disclosure format — the anchored +commitment is what makes the pair self-sufficient. + ### Definitions | Component | Source in proof JSON | Derivation | @@ -204,26 +270,86 @@ chain_hash = sha256_hex(canonical_json(chain_data)) | `timestamp` | `timestamp` | ISO 8601 UTC string (e.g. `2026-02-25T17:09:47Z`) | | `buyer_fingerprint` | `parties.buyer_fingerprint` | `SHA256(api_key)` — hash of the raw API key string | | `seller` | `parties.seller` | Target domain (e.g. `arkforge.fr`) | -| `upstream_timestamp` | `upstream_timestamp` | Upstream service's HTTP `Date` header. **Included in chain_data only when present and non-null** | -| `receipt_content_hash` | `provider_payment.receipt_content_hash` | SHA-256 hex of raw receipt bytes. **Included in chain_data only when present**. Strip the `sha256:` prefix | +| `upstream_timestamp` | `upstream_timestamp` | Upstream service's HTTP `Date` header. **Committed only when present and non-null** | +| `receipt_content_hash` | `provider_payment.receipt_content_hash` | SHA-256 hex of raw receipt bytes. **Committed only when present**. Strip the `sha256:` prefix | +| `commitments` | `commitments` | One hex commitment per committed field, published in full | + +### Backward compatibility + +`spec_version` selects the algorithm. Earlier proofs keep theirs; nothing is recomputed +or re-anchored retroactively. -### Backward compatibility (spec_version "1.1" and "2.0" — legacy) +| `spec_version` | Chain hash | +|---|---| +| `"3.0"` | Merkle root of per-field commitments (current) | +| `"1.2"`, `"2.1"` | `SHA256(canonical_json(chain_data))` over the **values** | +| `"1.1"`, `"2.0"`, absent | `SHA256` of the values concatenated as raw UTF-8, no separator (legacy) | + +**Values algorithm (spec_version "1.2" and "2.1")** + +``` +chain_data = { + "buyer_fingerprint": , "request_hash": , "response_hash": , + "seller": , "timestamp": , "transaction_id": , + // optional, only when present and non-null: + "upstream_timestamp": , "receipt_content_hash": , +} +chain_hash = SHA256(canonical_json(chain_data)) +``` -Proofs with `spec_version` `"1.1"`, `"2.0"`, or absent use the **legacy string concatenation formula**: +**Legacy algorithm (spec_version "1.1", "2.0", absent)** ``` input = request_hash + response_hash + transaction_id + timestamp + buyer_fingerprint + seller [+ upstream_timestamp if present] [+ receipt_content_hash (stripped of "sha256:" prefix) if present] - chain_hash = SHA256(input.encode("utf-8")).hexdigest() ``` -Use `spec_version` to select the algorithm: -- `"1.2"`, `"2.1"`: canonical JSON (current) -- `"1.1"`, `"2.0"`, absent: string concatenation (legacy) +**Why not raw concatenation?** Variable-length string concatenation without separators +creates preimage ambiguity: two different inputs can produce the same concatenated +string (e.g. `"ab"+"cd"` = `"a"+"bcd"`). Canonical JSON eliminated that in spec 1.2; +per-field commitments keep it and add third-party verifiability on top. + +## 2.2. Batch anchoring + +External anchors — an RFC 3161 timestamp and a Sigstore Rekor entry — MAY cover a +**batch** of proofs rather than a single one. Chain hashes accumulate, and the anchored +artefact is the RFC 6962 Merkle root over them, same primitive as the chain hash one +level down: + +``` +batch_root = MerkleRootRFC6962([ leaf(chain_hash_bytes) for each proof in the batch ]) +``` + +Each proof then carries its own inclusion proof down from that root: + +```json +"batch_anchor": { + "status": "anchored", + "batch_id": "batch_20260913_132956_516", + "leaf_index": 0, + "tree_size": 4, + "audit_path": ["", ""], + "root": "sha256:" +} +``` + +A verifier walks the path from its leaf to the claimed root (RFC 6962 §2.1.1), then +checks the external anchors against **that root** rather than against the chain hash. + +Three checks are not optional: + +- the walk must reach the claimed root; +- `leaf_index` must lie in `[0, tree_size)`; +- `len(audit_path)` must equal the length a tree of `tree_size` requires for + `leaf_index` — that length is deterministic. Without it, an overstated `tree_size` is + accepted: the walk consumes the real siblings, reaches the real root and stops early, + with every other check satisfied. -**Why canonical JSON?** Variable-length string concatenation without separators creates preimage ambiguity: two different inputs can produce the same concatenated string (e.g. `"ab"+"cd"` = `"a"+"bcd"`). Canonical JSON eliminates this by encoding field boundaries explicitly. +**Pending state.** Between issuance and batch close a proof has no external anchor. +`batch_anchor.status` is then `"pending"`, and a verifier MUST report that as waiting, +never as tampering. A proof that is merely waiting is not a forged one. ## 2.1. Payment evidence (v2.0) @@ -315,7 +441,40 @@ Given a proof JSON, any party can verify the integrity of chain-hash-bound field First, determine the algorithm from `spec_version`: -### Current algorithm (spec_version "1.2" / "2.1") +### Current algorithm (spec_version "3.0") + +No field value is needed: the commitments are published and the chain hash is their +Merkle root. + +```python +import json, hashlib + +proof = json.loads(open("proof.json").read()) +c = proof["commitments"] + +def leaf(x): return hashlib.sha256(b"\x00" + x).digest() +def node(l, r): return hashlib.sha256(b"\x01" + l + r).digest() + +def merkle_root(leaves): + if len(leaves) == 1: + return leaves[0] + k = 1 + while k * 2 < len(leaves): + k *= 2 + return node(merkle_root(leaves[:k]), merkle_root(leaves[k:])) + +leaves = [leaf(bytes.fromhex(c[f].removeprefix("sha256:"))) for f in sorted(c)] +computed = merkle_root(leaves).hex() +expected = proof["hashes"]["chain"].removeprefix("sha256:") +print("VERIFIED" if computed == expected else "TAMPERED") +``` + +Recomputing the chain hash proves self-consistency only — whoever fabricates a proof +produces coherent hashes. The evidence is the RFC 3161 timestamp and the Sigstore Rekor +entry on the anchored hash (the batch root when `batch_anchor.status` is `"anchored"`, +the chain hash itself otherwise). + +### Values algorithm (spec_version "1.2" / "2.1") ```python import json, hashlib diff --git a/check_consistency.py b/check_consistency.py index 5fd2117..a7bc0d2 100644 --- a/check_consistency.py +++ b/check_consistency.py @@ -27,6 +27,56 @@ def sha256(s: str) -> str: return hashlib.sha256(s.encode("utf-8")).hexdigest() +# --- spec 3.0 primitives: per-field commitments and RFC 6962 Merkle ---------- + +def commit(field: str, nonce_hex: str, value) -> str: + """sha256(field || 0x00 || nonce || canonical_json(value)).""" + preimage = (field.encode("utf-8") + b"\x00" + bytes.fromhex(nonce_hex) + + canonical_json(value).encode("utf-8")) + return hashlib.sha256(preimage).hexdigest() + + +def leaf_hash(data: bytes) -> bytes: + return hashlib.sha256(b"\x00" + data).digest() + + +def node_hash(left: bytes, right: bytes) -> bytes: + return hashlib.sha256(b"\x01" + left + right).digest() + + +def merkle_root(leaves): + """RFC 6962 Merkle Tree Hash. The odd node is promoted, never duplicated.""" + if len(leaves) == 1: + return leaves[0] + k = 1 + while k * 2 < len(leaves): + k *= 2 + return node_hash(merkle_root(leaves[:k]), merkle_root(leaves[k:])) + + +def inclusion_root(leaf: bytes, index: int, size: int, path): + """RFC 6962 inclusion-proof walk. Returns (root, siblings_consumed).""" + h, idx, sz, i = leaf, index, size, 0 + while sz > 1: + if i >= len(path): + return h, i + if idx % 2 == 1: + h = node_hash(path[i], h) + i += 1 + elif idx + 1 < sz: + h = node_hash(h, path[i]) + i += 1 + idx //= 2 + sz = (sz + 1) // 2 + return h, i + + +def commitments_root(commitments: dict) -> str: + """Spec 3.0 chain hash: Merkle root over the commitments, fields sorted.""" + return merkle_root([leaf_hash(bytes.fromhex(commitments[f])) + for f in sorted(commitments)]).hex() + + def check_spec_version(): """spec_version in test-vectors.json must appear in SPEC.md examples.""" vectors = json.loads(VECTORS.read_text()) @@ -60,6 +110,30 @@ def check_test_vectors(): inp = v["input"] exp = v["expected"] + # Batch anchoring vector: no request/response, only chain hashes and a tree + if v.get("algorithm") == "batch_merkle": + leaves = [leaf_hash(bytes.fromhex(c)) for c in inp["chain_hashes"]] + root = merkle_root(leaves).hex() + if root != exp["root"]: + print(f"FAIL [{name}]: batch root mismatch") + print(f" got: {root}") + print(f" expected: {exp['root']}") + ok = False + continue + bad = False + for item in exp["inclusion"]: + i = item["leaf_index"] + path = [bytes.fromhex(h) for h in item["audit_path"]] + walked, consumed = inclusion_root(leaves[i], i, exp["tree_size"], path) + if walked.hex() != exp["root"] or consumed != len(path): + print(f"FAIL [{name}]: inclusion proof for leaf {i} does not reach the root") + bad = True + if bad: + ok = False + else: + print(f"OK [{name}]: batch root and {len(exp['inclusion'])} inclusion proofs verified") + continue + # Canonical JSON canon_req = canonical_json(inp["request"]) canon_resp = canonical_json(inp["response"]) @@ -94,7 +168,34 @@ def check_test_vectors(): # Chain hash — algorithm depends on vector's spec_version algo = v.get("algorithm", "concatenation") - if algo == "canonical_json": + if algo == "commitments": + chain_data = { + "request_hash": req_hash, + "response_hash": resp_hash, + "transaction_id": inp["payment_intent_id"], + "timestamp": inp["timestamp"], + "buyer_fingerprint": buyer_fp, + "seller": inp["seller"], + } + if inp.get("upstream_timestamp"): + chain_data["upstream_timestamp"] = inp["upstream_timestamp"] + if inp.get("receipt_content_hash"): + chain_data["receipt_content_hash"] = inp["receipt_content_hash"] + if chain_data != exp["chain_data"]: + print(f"FAIL [{name}]: chain_data mismatch") + ok = False + continue + commitments = {f: commit(f, inp["nonces"][f], chain_data[f]) for f in chain_data} + if commitments != exp["commitments"]: + for f in commitments: + if commitments[f] != exp["commitments"].get(f): + print(f"FAIL [{name}]: commitment for '{f}' mismatch") + print(f" got: {commitments[f]}") + print(f" expected: {exp['commitments'].get(f)}") + ok = False + continue + chain_hash = commitments_root(commitments) + elif algo == "canonical_json": chain_data = { "buyer_fingerprint": buyer_fp, "request_hash": req_hash, diff --git a/test-vectors.json b/test-vectors.json index 408e884..ca85de6 100644 --- a/test-vectors.json +++ b/test-vectors.json @@ -1,10 +1,9 @@ { - "spec_version": "2.1.3", + "spec_version": "3.0.0", "algorithm": "SHA-256", "canonical_json_rule": "json.dumps(data, sort_keys=True, separators=(\",\", \":\"))", - "chain_formula_current": "SHA256(canonical_json({buyer_fingerprint, request_hash, response_hash, seller, timestamp, transaction_id [, upstream_timestamp] [, receipt_content_hash]}))", - "chain_formula_legacy": "SHA256(request_hash + response_hash + transaction_id + timestamp + buyer_fingerprint + seller [+ upstream_timestamp] [+ receipt_content_hash])", - "note": "Vectors 1-7 use the LEGACY string-concatenation algorithm (spec_version 1.1/2.0). Vectors 8-9 use the CURRENT canonical-JSON algorithm (spec_version 1.2/2.1). transaction_id = payment.transaction_id from the proof JSON. buyer_fingerprint = SHA256(api_key). In test vector inputs, the field is named payment_intent_id for historical reasons.", + "chain_formula_current": "MerkleRoot_RFC6962({ SHA256(field || 0x00 || nonce || canonical_json(value)) for each chain field }, leaves ordered by field name)", + "note": "Vectors 1-7 use the LEGACY string-concatenation algorithm (spec_version 1.1/2.0). Vectors 8-9 hash the canonical JSON of the values themselves (spec_version 1.2/2.1). Vectors 10-11 are spec_version 3.0: the chain hash is the RFC 6962 Merkle root of one commitment per field, so it is recomputable from the published commitments alone, with no field value. Their nonces are fixed here to make the vector reproducible; a real proof draws 32 fresh random bytes per field and per proof. Vector 12 covers batch anchoring: the Merkle root over several chain hashes, and one inclusion path per leaf. transaction_id = payment.transaction_id from the proof JSON. buyer_fingerprint = SHA256(api_key). In test vector inputs, the field is named payment_intent_id for historical reasons.", "vectors": [ { "name": "minimal_transaction", @@ -245,6 +244,164 @@ "canonical_chain_data": "{\"buyer_fingerprint\":\"7c8f263e06d5ce4681f750ad64ede882a4ebd87de60f9ae0e6b06f0300645a11\",\"receipt_content_hash\":\"af65b75f3901dfd0ed9590a009bf7283e318801e15733bf68403219967d4edb7\",\"request_hash\":\"0987aa49eb45583406b66c77ea6f35498bd318b81040bec9c54ab439114abe42\",\"response_hash\":\"bad7c7f7f632182e9d746c9a4a02aea5f526a6a76c5108c4a98a7c4823fdbef2\",\"seller\":\"arkforge.fr\",\"timestamp\":\"2026-01-15T12:00:00Z\",\"transaction_id\":\"pi_test_abc123\",\"upstream_timestamp\":\"Wed, 15 Jan 2026 12:00:01 GMT\"}", "chain_hash": "0ad9bb1baae5431ce793195bc6e89f8acd25d6de99a721abd44ed58989efaa4d" } + }, + { + "name": "commitments_minimal", + "input": { + "request": { + "repo_url": "https://github.com/example/app" + }, + "response": { + "files_scanned": 42, + "frameworks": [ + "openai" + ] + }, + "payment_intent_id": "pi_test_abc123", + "timestamp": "2026-01-15T12:00:00Z", + "api_key": "mcp_test_example_key", + "seller": "arkforge.fr", + "nonces": { + "buyer_fingerprint": "957c0534699e32745769701969dbb1817ff0f799e5758ce6388b68d0ec8ae143", + "request_hash": "a11cfe76ceeba936f9188e40f84bf8f05b1e210239ff02a098f2e0b816007afb", + "response_hash": "99a1b895042b7b3a9b033275e57fdf645eae388ab0a267e0d7def2b6794b215b", + "seller": "6214efdff66beb53dc768c5b5c4c464ecd79727b4cbd738b016d122ffa97ce22", + "timestamp": "8ae28267bebdc268e78ac2b61902c58da2ac3d0e4588f597d271a05ce5e8e025", + "transaction_id": "e2b782653f02d6802d29c2da285a3b7335d292100d5c4c32d9a61750076cbf26" + } + }, + "expected": { + "canonical_request": "{\"repo_url\":\"https://github.com/example/app\"}", + "canonical_response": "{\"files_scanned\":42,\"frameworks\":[\"openai\"]}", + "request_hash": "0987aa49eb45583406b66c77ea6f35498bd318b81040bec9c54ab439114abe42", + "response_hash": "bad7c7f7f632182e9d746c9a4a02aea5f526a6a76c5108c4a98a7c4823fdbef2", + "buyer_fingerprint": "7c8f263e06d5ce4681f750ad64ede882a4ebd87de60f9ae0e6b06f0300645a11", + "chain_data": { + "request_hash": "0987aa49eb45583406b66c77ea6f35498bd318b81040bec9c54ab439114abe42", + "response_hash": "bad7c7f7f632182e9d746c9a4a02aea5f526a6a76c5108c4a98a7c4823fdbef2", + "transaction_id": "pi_test_abc123", + "timestamp": "2026-01-15T12:00:00Z", + "buyer_fingerprint": "7c8f263e06d5ce4681f750ad64ede882a4ebd87de60f9ae0e6b06f0300645a11", + "seller": "arkforge.fr" + }, + "commitments": { + "request_hash": "687e2a14776ec07976443c56751695449ba534ab52c269760db5f8b1340304e9", + "response_hash": "c718512b4cccfee053edf9029b85b500682fde09f2686c8ae13532d5f18e1d0c", + "transaction_id": "f224d10886a95796938b5f2038db26cf2ac834607cd0f44e62ac375c4b778263", + "timestamp": "e3e6a05e8bddbdf71d8e3cde54b598414d6d5552b2782a14f3e7d8831ba6f7cb", + "buyer_fingerprint": "cc06c13453ff06be01156f45d045156501f918561bea9a133c4eb7a0c5a4708a", + "seller": "eb98fa4a815e0c51f702a450e9b2f53d30475136c3d58d8b9994ced6420c6202" + }, + "chain_hash": "dc2f9b7f8c9b455cd5c2068bac5b210a9f921b46f59569d57b28abcdfd70b8f2" + }, + "spec_version": "3.0", + "algorithm": "commitments" + }, + { + "name": "commitments_upstream_and_receipt", + "input": { + "request": { + "q": "état des lieux" + }, + "response": { + "ok": true + }, + "payment_intent_id": "pi_test_xyz789", + "timestamp": "2026-03-02T08:30:00Z", + "api_key": "mcp_test_example_key", + "seller": "api.example.com", + "upstream_timestamp": "Mon, 02 Mar 2026 08:30:01 GMT", + "receipt_content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "nonces": { + "buyer_fingerprint": "5ae157e9afe790b71ea0352b1b50106200a8dd97c033772cca6e1756a139e46c", + "receipt_content_hash": "5d1780afe127884675d8df559fb8abf4ffd18939427ea3c12c2822fbba5a4d02", + "request_hash": "8219bef40bc037c01a2ac6c40c1460395dafa6e64df5923917b46f8c5cebe630", + "response_hash": "a852b87370cb7a50e5df52dea791257ea6fd5de0a3a2dbacd3e1f1558ceac79a", + "seller": "c55cfbadd08e2cdae9336ca35d5933bd8eaa5c080784a5f65bfb37976206aa67", + "timestamp": "84a93b0cb837649e2431dd83972634922db34603e4b4514743dce0facd2a548b", + "transaction_id": "f589f4afab07110d46de3a7bd9b72c74e0388361a3f80adc3d54562e31cb6d6e", + "upstream_timestamp": "a74f6ff38d26356470c782a28f572671ccdd828adb400be0c978c9faa4523125" + } + }, + "expected": { + "canonical_request": "{\"q\":\"\\u00e9tat des lieux\"}", + "canonical_response": "{\"ok\":true}", + "request_hash": "060a6a36ad45a0a1cca5f921d10b1829a98b557319062771d250cbf1b6b96e2a", + "response_hash": "4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93", + "buyer_fingerprint": "7c8f263e06d5ce4681f750ad64ede882a4ebd87de60f9ae0e6b06f0300645a11", + "chain_data": { + "request_hash": "060a6a36ad45a0a1cca5f921d10b1829a98b557319062771d250cbf1b6b96e2a", + "response_hash": "4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93", + "transaction_id": "pi_test_xyz789", + "timestamp": "2026-03-02T08:30:00Z", + "buyer_fingerprint": "7c8f263e06d5ce4681f750ad64ede882a4ebd87de60f9ae0e6b06f0300645a11", + "seller": "api.example.com", + "upstream_timestamp": "Mon, 02 Mar 2026 08:30:01 GMT", + "receipt_content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "commitments": { + "request_hash": "bbc116eacb1a0786debe941ac20800b29aa37007c54ffb28a6c373982c04470b", + "response_hash": "8426f602bf93e2b9cb9dd580f6f97173f3fb116e56f123600e31cdbfd433a2f3", + "transaction_id": "de9664bacee2dc239e5f0361229be2c1d04d818c52b7a67df6af1063ff55d038", + "timestamp": "3cb13ee4f74b943eb05483b30f8039dfbb63918521c4fed201e13e0a88d75e35", + "buyer_fingerprint": "01071a01a568269a389224755886d2f41f17269c4e859c27536d6a0f0e309357", + "seller": "e3600754d413dfb31999462773a5dbb5ea25cdb3e643a939b840e7c1a0946a36", + "upstream_timestamp": "472a18f26a7ddd4e82e129a2bc4839293da528c03d91ba9ded7829b491fd6425", + "receipt_content_hash": "054d8cf1bb7e92dcea3903b94da05ec0a7e38daab142a2c31e570d7f45f4e50c" + }, + "chain_hash": "2c6e0bc4b4f3f8547ba289bb1a8f04ddd3ea91d077dd8aa997b38d5e4f2ff07c" + }, + "spec_version": "3.0", + "algorithm": "commitments" + }, + { + "name": "batch_anchor_inclusion", + "input": { + "chain_hashes": [ + "dc2f9b7f8c9b455cd5c2068bac5b210a9f921b46f59569d57b28abcdfd70b8f2", + "2c6e0bc4b4f3f8547ba289bb1a8f04ddd3ea91d077dd8aa997b38d5e4f2ff07c", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + }, + "expected": { + "tree_size": 4, + "root": "b627978e58b7414ca3972bbc8c3a80c77899165bfcd876ac4eccefb7f59bd974", + "inclusion": [ + { + "leaf_index": 0, + "audit_path": [ + "00cb7c8c41b3733d6b4d741d0d0e21bc289001d7a76d8a101b74d594d8d9f112", + "03938e2c8f758e6cae443d499b41c899c373eb0c0198bae61796a069f2b05904" + ] + }, + { + "leaf_index": 1, + "audit_path": [ + "09285ee14f6c28a1d621f5c8942b85b2daec7c676fb9b63ab3a54023f6de00e3", + "03938e2c8f758e6cae443d499b41c899c373eb0c0198bae61796a069f2b05904" + ] + }, + { + "leaf_index": 2, + "audit_path": [ + "4f16119d36ccd0da91102f57692d73934fd0ad2494280df88449accedbbfb7ea", + "3e7faf3d801ba6b915cb5a4c26171a3f397a0b8d707904135e0618357ff5100f" + ] + }, + { + "leaf_index": 3, + "audit_path": [ + "e0bb82791bae3c50bd9c20fa4ccdcb8064a56e5c12bc69b07e6712ac9b4429e6", + "3e7faf3d801ba6b915cb5a4c26171a3f397a0b8d707904135e0618357ff5100f" + ] + } + ] + }, + "spec_version": "3.0", + "algorithm": "batch_merkle" } - ] + ], + "chain_formula_values": "SHA256(canonical_json({buyer_fingerprint, request_hash, response_hash, seller, timestamp, transaction_id [, upstream_timestamp] [, receipt_content_hash]})) — spec_version 1.2 / 2.1", + "chain_formula_legacy": "SHA256(request_hash + response_hash + transaction_id + timestamp + buyer_fingerprint + seller [+ upstream_timestamp] [+ receipt_content_hash])" }