From 62a81b34725e8a4bf214b9530f39c77f9a79e6a7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 04:35:19 -0400 Subject: [PATCH] mesh/1 + AR-1: receipts commit to the output; deterministic serves replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mesh receipt bound what was asked (request_id) and what it cost, and nothing about what came back, so no one could later show which answer a receipt was for. AR-1 had the same gap beside input_hash. mesh/1 (SPEC §6.4): - output_commitment: a salted BLAKE3 commitment over the request id, the digest of the prompt payload the engine was handed, and the output tokens. It is a commitment, not a hash, because a short output is reversible from its hash. The opening is derived per request from a key drawn from the steward's signing key, so a restarted steward can still disclose it. The field is absent for a typed decision, whose answer the engine interface does not carry, and omitted when absent, so older receipts keep their bytes. - The payload is bound because prompt_hash addresses the prompt's bytes while an engine is handed token ids, so request_id alone cannot confirm what a replay runs on. - ServingEngine::deterministic (default false; the reference engine true). - mesh_core::replay re-executes a sealed serve. Its verdicts: - Verified: the re-run opens the commitment. - Diverged { first_difference }: only when a disclosed output opens the commitment on the same payload and opening, so the mismatch is the serve's and not the replayer's input. - NotReplayable(why): the engine samples; another backend, recipe or weights; another request; no commitment; or a mismatch nothing attributes. - The conformance pack is regenerated (the reference receipt gains the field; the envelope grows from 663 to 747 bytes). AR-1: output_hash, an optional field after input_hash, sealed only when bound (§2.3). It recommends a commitment over a plain hash and names mesh's output_commitment as one. The published AR-1 vectors regenerate byte-identical. Also: ar-verify's `len() % 2 == 0` becomes `is_multiple_of(2)`, a clippy error that predates this change, so the crate is clippy-clean. Tests: - mesh: the commitment binds request, opening, payload and tokens; a generation commits and a decision does not; deterministic replay verifies and locates a drift; replay refuses what it cannot settle, including a wrong payload with the honest output disclosed; a restarted steward reopens. - AR-1: output_hash sealed when bound and absent otherwise. - Mutation testing: 20 mutants, all killed (13, then 7 more after binding the payload; one of the 13 targets code since replaced). Verified on the build host: - mesh-rs: 94 tests plus 19 behind features; clippy and rustdoc clean. - jcp-rs: 565 tests and the conformance binary; jcp-action-receipt clippy-clean. - Repo checks pass. Co-Authored-By: Claude Opus 5.5 --- .../jcp-action-receipt/docs/AR-1-spec.md | 16 + .../examples/emit_vectors.rs | 1 + .../jcp-action-receipt/examples/end_to_end.rs | 1 + .../jcp-action-receipt/src/bin/ar-verify.rs | 2 +- .../crates/jcp-action-receipt/src/lib.rs | 33 ++ mesh/SPEC.md | 70 +++- mesh/conformance/v1/reference/envelope.hex | 2 +- mesh/conformance/v1/reference/receipt.json | 1 + mesh/conformance/v1/reference/signed_core.hex | 2 +- .../crates/mesh-conformance/tests/pack.rs | 4 +- mesh/mesh-rs/crates/mesh-core/src/lib.rs | 327 +++++++++++++++++- mesh/mesh-rs/crates/mesh-engine/src/lib.rs | 25 +- mesh/mesh-rs/crates/mesh-receipt/src/lib.rs | 97 ++++++ 13 files changed, 565 insertions(+), 16 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-action-receipt/docs/AR-1-spec.md b/jcp/jcp-rs/crates/jcp-action-receipt/docs/AR-1-spec.md index 417616dc5..ed6a9ca11 100644 --- a/jcp/jcp-rs/crates/jcp-action-receipt/docs/AR-1-spec.md +++ b/jcp/jcp-rs/crates/jcp-action-receipt/docs/AR-1-spec.md @@ -73,6 +73,7 @@ ActionReceipt { // binding 3 — work work_receipt: ContentId input_hash: Hash + output_hash: Hash? // a commitment to what the work returned (§2.3) joules_uj: u64 // the work's cost, graded by energy_provenance energy_provenance: "HwShunt" | "ModelBased" | "Estimator" @@ -118,6 +119,21 @@ arithmetic. reading only when `energy_provenance` is `HwShunt`, and a verifier or display MUST NOT present it as a measurement otherwise. +### 2.3 `output_hash` commits to what came back + +Without it a receipt shows what went in (`input_hash`) and what changed in the +world (`effect`), and not what the work returned. It therefore cannot show +which answer it was for. Where the producer binds the output, it seals +`output_hash`. It is omitted when absent, so a receipt sealed before the field +existed keeps its bytes. + +A producer SHOULD seal a **commitment** rather than a plain hash wherever the +output can be short. A plain hash of a one-token answer can be reversed by +trying every answer, and a receipt is shown to parties who were not owed the +output. `mesh/1`'s `output_commitment` (mesh SPEC §6.4), which commits under +an opening its steward discloses per receipt, is such a commitment and can be +carried here as it is. + ## 3. The three rules These MUST be enforced at verification, re-derived from the wire projection — diff --git a/jcp/jcp-rs/crates/jcp-action-receipt/examples/emit_vectors.rs b/jcp/jcp-rs/crates/jcp-action-receipt/examples/emit_vectors.rs index c898d5421..f5f0123aa 100644 --- a/jcp/jcp-rs/crates/jcp-action-receipt/examples/emit_vectors.rs +++ b/jcp/jcp-rs/crates/jcp-action-receipt/examples/emit_vectors.rs @@ -47,6 +47,7 @@ fn base() -> ActionReceipt { grounding: "hardware".into(), work_receipt: cid("jcr1/resolution"), input_hash: "a".repeat(64), + output_hash: None, joules_uj: 19_538_280, energy_provenance: "HwShunt".into(), effect: Effect { diff --git a/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs b/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs index a5b977fa0..d463d3093 100644 --- a/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs +++ b/jcp/jcp-rs/crates/jcp-action-receipt/examples/end_to_end.rs @@ -216,6 +216,7 @@ fn main() -> Result<(), Box> { grounding: if is_hw { "hardware" } else { "software" }.into(), work_receipt: work_id, input_hash: ContentId::of_bytes(b"input/quarterly-report").to_hex(), + output_hash: None, joules_uj, energy_provenance: provenance.into(), effect: Effect { diff --git a/jcp/jcp-rs/crates/jcp-action-receipt/src/bin/ar-verify.rs b/jcp/jcp-rs/crates/jcp-action-receipt/src/bin/ar-verify.rs index cc952201d..485f6077c 100644 --- a/jcp/jcp-rs/crates/jcp-action-receipt/src/bin/ar-verify.rs +++ b/jcp/jcp-rs/crates/jcp-action-receipt/src/bin/ar-verify.rs @@ -92,7 +92,7 @@ fn read_envelope(path: &str) -> Result, String> { let raw = std::fs::read(path).map_err(|e| e.to_string())?; if let Ok(text) = std::str::from_utf8(&raw) { let t: String = text.split_whitespace().collect(); - if !t.is_empty() && t.len() % 2 == 0 && t.bytes().all(|c| c.is_ascii_hexdigit()) { + if !t.is_empty() && t.len().is_multiple_of(2) && t.bytes().all(|c| c.is_ascii_hexdigit()) { return hex::decode(&t).map_err(|e| e.to_string()); } } diff --git a/jcp/jcp-rs/crates/jcp-action-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-action-receipt/src/lib.rs index 7aa48a722..afc409046 100644 --- a/jcp/jcp-rs/crates/jcp-action-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-action-receipt/src/lib.rs @@ -201,6 +201,18 @@ pub struct ActionReceipt { pub work_receipt: ContentId, /// Hash of the input the work consumed. pub input_hash: String, + /// A commitment to the output the work produced, where the producer binds + /// one. Without it the receipt shows what went in and what changed in the + /// world, and not what the work returned, so it cannot show which answer + /// it was for. + /// + /// A producer SHOULD seal a commitment rather than a plain hash where the + /// output can be short. A hash of a one-token answer can be reversed by + /// trying every answer, and a receipt is shown to parties who were not owed + /// the output. `mesh/1`'s `output_commitment` is one such commitment, and + /// can be carried here as it is. Omitted from the wire when `None`, so every + /// receipt sealed before the field existed keeps its bytes. + pub output_hash: Option, /// The work's energy cost in microjoules. Whether it is a hardware /// reading, a model or an estimate is not this field's to say: it is /// graded by [`energy_provenance`](Self::energy_provenance), and is a @@ -263,6 +275,9 @@ impl ActionReceipt { o.insert("work_receipt".into(), json!(self.work_receipt.to_hex())); o.insert("input_hash".into(), json!(self.input_hash)); + if let Some(h) = &self.output_hash { + o.insert("output_hash".into(), json!(h)); + } o.insert("joules_uj".into(), json!(self.joules_uj)); o.insert("energy_provenance".into(), json!(self.energy_provenance)); @@ -408,6 +423,7 @@ mod tests { grounding: "hardware".into(), work_receipt: cid(6), input_hash: "a".repeat(64), + output_hash: None, joules_uj: 19_538_280, energy_provenance: "HwShunt".into(), effect: Effect { @@ -564,4 +580,21 @@ mod tests { assert_eq!(v.wire["energy_provenance"], prov); } } + + /// The output commitment is sealed when the producer binds one, read back + /// by the verifier, and absent otherwise, so a receipt without it keeps + /// the bytes it sealed to before the field existed. + #[test] + fn an_output_commitment_is_sealed_when_bound_and_absent_otherwise() { + let sk = key(); + let bare = base(); + assert!(!bare.to_wire().as_object().unwrap().contains_key("output_hash")); + + let mut bound = base(); + bound.output_hash = Some("b".repeat(64)); + let env = bound.seal(&sk).unwrap(); + let v = verify(&env, &sk.verifying_key(), &bound.steward).unwrap(); + assert_eq!(v.wire["output_hash"], "b".repeat(64).as_str()); + assert_ne!(bound.id().unwrap(), bare.id().unwrap(), "the output is in the receipt's identity"); + } } diff --git a/mesh/SPEC.md b/mesh/SPEC.md index f64bcdc8e..2321f97d5 100644 --- a/mesh/SPEC.md +++ b/mesh/SPEC.md @@ -434,9 +434,10 @@ drops to a cheaper path — reports what it served. Omitting it is conformant an means `fidelity_met` rests on the advertisement (§3.2.1). The input token payload travels **with** the serve call, alongside the request — -it is never part of the request's sealed identity and never enters the receipt, -which records only the content-addressed `prompt_hash` (§3.1). A receipt is -therefore safe to publish without disclosing the prompt. +it is never part of the request's sealed identity and never enters the receipt +in the clear. The receipt records the content-addressed `prompt_hash` (§3.1), +and the payload's digest only inside the output commitment, under its opening +(§6.4). A receipt is therefore safe to publish without disclosing the prompt. `tokens_accepted` is the denominator of the wedge metric. For greedy decode it equals `tokens_out`; under speculative decoding it is the number of proposed @@ -445,6 +446,11 @@ prefix/result cache (energy saved). An engine MUST do real work proportional to the tokens it serves so the surrounding meter reads a real span; it MUST NOT self-report energy. +An engine declares whether it is **deterministic**: whether serving the same +request and prompt payload again yields the same tokens. Only a deterministic +engine's serve can be re-executed against its receipt (§6.4). An engine that +samples MUST NOT declare it, and the default is not to. + The reference engine (`mesh-engine::ReferenceEngine`) is deterministic and weights-free, for conformance. Production backends — a quantized device-resident decoder, a high-throughput batching server — implement the same interface and @@ -485,6 +491,7 @@ and nulls; booleans are encoded as `0|1` uints and absent options dropped. | `uj_per_option_scored` | the finer decision figure — `energy.uj() / options_scored` | | `energy_provenance` | `HwShunt | ModelBased | Estimator` | | `grant_breach` | the JCP denial label when debiting the metered joules breached the grant, e.g. `caveat-unsatisfied` for a per-call cost caveat (OPTIONAL — §2; absent means the debit was within the grant, or the serve rode on none) | +| `output_commitment` | hex commitment to the output tokens of a generation and the prompt payload they were served on (§6.4; absent for a typed decision, whose answer the engine interface does not carry, and in receipts sealed before the field existed) | | `sequence`, `prev` | hash-chain position and prior envelope's content id | | `nonce`, `steward` | per-receipt nonce and the sealing identity (COSE `kid`) | @@ -506,6 +513,63 @@ receipt takes the next sequence and chains to the last envelope sealed, rather than starting a new chain at 0, so a restart is not mistaken for a log that lost its history. +### §6.4 The output commitment, and replay + +A receipt that binds what was asked (`request_id`, which content-addresses the +prompt) and what it cost, but nothing about what came back, cannot show which +answer it was for. A generation's receipt therefore carries +`output_commitment`: + +```text +payload = BLAKE3("mesh/1 prompt payload\0" ‖ prompt) +output_commitment = BLAKE3("mesh/1 output commitment\0" ‖ opening ‖ request_id ‖ payload ‖ tokens) +``` + +Here `prompt` is the input token payload the engine was handed (§5) and +`tokens` are the output token ids, each as little-endian `u32`s in order; +`request_id` and `payload` are their 32 bytes; and `opening` is a 32-byte +value the steward derives per request from a key it holds. In the reference +steward, the key is derived from its signing key, so a steward that restarts +can disclose any receipt's opening without having stored it. + +- **It is a commitment, not a hash.** A plain hash of a short output, such as a + one-token answer, can be reversed by trying every output, and a receipt is + shown to parties who were not owed the answer. Without its opening, the + commitment reveals nothing about the tokens. +- **Disclosure.** A steward that discloses an output for one receipt discloses + that receipt's opening and `payload` with it. Anyone can then check that this + is the output the receipt sealed. The opening binds `request_id`, so it opens + no other receipt. +- **The payload is bound.** `prompt_hash` addresses the prompt's bytes, and an + engine is handed token ids. Nothing ties the two together without the + tokenizer, so `request_id` alone cannot confirm the payload a replay runs on. + The commitment binds it instead. +- **Replay.** Where the engine is deterministic (§5: its tokens are a function + of the request and the prompt payload alone), a party holding the prompt + payload and the opening can re-execute the serve and compare. + - Re-execution yields one of three verdicts: *verified* (the re-run opens the + commitment), *diverged*, or *not replayable*. + - It is *diverged* only when a disclosed output opens the commitment on the + payload the replay ran. That confirms the opening, the payload and the + output together, so the mismatch is the serve's. The verdict names the + first token at which the two outputs part. + - A replay MUST NOT report a divergence it cannot attribute to the serve. It + is *not replayable* when: + - the engine samples; + - it is not the backend and recipe the receipt names, or not the weights, + where the receipt sealed `weights_digest`; + - the request is not the receipt's; + - the receipt commits to no output; + - the re-run does not open the commitment, and no disclosed output opens it + on the replay's payload and opening. The mismatch might then be the + replayer's own input. + - A sampled generation is not expected to repeat, and re-running it settles + nothing. + +These hold a serve to its record. Whether such a record carries any weight in a +given dispute is not this specification's to say. It is evidence anyone can +produce and check without the steward's help, opening apart. + --- ## §7 Conformance diff --git a/mesh/conformance/v1/reference/envelope.hex b/mesh/conformance/v1/reference/envelope.hex index bb1b7ee38..a06e1b260 100644 --- a/mesh/conformance/v1/reference/envelope.hex +++ b/mesh/conformance/v1/reference/envelope.hex @@ -1 +1 @@ -d284583ea201270458386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335aa059020fb4656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e7475756a5f7065725f61636365707465645f746f6b656e19c350584069106eee1557917c7e7da7498320aab7b17e9dbc0ad36436922269bec771fb4eb6fd0741819a00ce95ac7248b62dda77737e606b1b5492c2f863eea3f6723d08 +d284583ea201270458386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335aa0590263b5656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e74716f75747075745f636f6d6d69746d656e7478403763393834386661623830316362386563336463316461656463393534343065623732623766633237643734633862306335366634306539346563626436396675756a5f7065725f61636365707465645f746f6b656e19c350584023281d57bf71b2e06936d3a5ca9fbc4503f27a9b18e9e2ee44e352a7c25cab8646ded99d74ca53a638ce15bb654afc0bfc3acf7ac5ff941898d3a1cd1cf01100 diff --git a/mesh/conformance/v1/reference/receipt.json b/mesh/conformance/v1/reference/receipt.json index efc35a0a3..e864c521b 100644 --- a/mesh/conformance/v1/reference/receipt.json +++ b/mesh/conformance/v1/reference/receipt.json @@ -8,6 +8,7 @@ "measured_uj": 3200000, "mesh_receipt": "mesh/1", "nonce": "3adc393e8a5a2053", + "output_commitment": "7c9848fab801cb8ec3dc1daedc95440eb72b7fc27d74c8b0c56f40e94ecbd69f", "recipe": "ssm-1b", "request_id": "3adc393e8a5a2053049e46650e1f15ffc529c0d5ae6872f2d19d2ea8abfa364e", "route": "cheapest-sufficient: tiny at 3550000 µJ, fidelity F1 ≥ floor F1 (weighed 4 backend(s))", diff --git a/mesh/conformance/v1/reference/signed_core.hex b/mesh/conformance/v1/reference/signed_core.hex index 56223e219..42add37d7 100644 --- a/mesh/conformance/v1/reference/signed_core.hex +++ b/mesh/conformance/v1/reference/signed_core.hex @@ -1 +1 @@ -b4656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e7475756a5f7065725f61636365707465645f746f6b656e19c350 +b5656e6f6e6365703361646333393365386135613230353365726f757465785963686561706573742d73756666696369656e743a2074696e79206174203335353030303020c2b54a2c20666964656c69747920463120e289a520666c6f6f722046312028776569676865642034206261636b656e6428732929667265636970656673736d2d3162676261636b656e646474696e79677374657761726478386469643a6b65793a7a364d6b76447147543534635865735947764142704631556170564e776a43715263616669345078365468763554335a6873657175656e63650069746f6b656e735f696e076a726571756573745f69647840336164633339336538613561323035333034396534363635306531663135666663353239633064356165363837326632643139643265613861626661333634656a746f6b656e735f6f757418406b6d656173757265645f756a1a0030d4006c657374696d617465645f756a1a0030d4006c666964656c6974795f6d6574016c6d6573685f72656365697074666d6573682f316d737065635f6163636570746564006d737065635f70726f706f736564006e666964656c6974795f666c6f6f72016f746f6b656e735f616363657074656418407063616368655f6869745f746f6b656e730071656e657267795f70726f76656e616e63656748775368756e74716f75747075745f636f6d6d69746d656e7478403763393834386661623830316362386563336463316461656463393534343065623732623766633237643734633862306335366634306539346563626436396675756a5f7065725f61636365707465645f746f6b656e19c350 diff --git a/mesh/mesh-rs/crates/mesh-conformance/tests/pack.rs b/mesh/mesh-rs/crates/mesh-conformance/tests/pack.rs index 5b0c99a1f..45fccd7b5 100644 --- a/mesh/mesh-rs/crates/mesh-conformance/tests/pack.rs +++ b/mesh/mesh-rs/crates/mesh-conformance/tests/pack.rs @@ -164,7 +164,9 @@ fn envelope_is_reproduced_from_signed_core_and_rfc_9052_alone() { assert_eq!(sk.verifying_key().to_bytes(), pubkey, "meta.json pubkey is not the seed's"); let rebuilt = cose_sign1(&protected, &core, &sk.sign(&to_sign).to_bytes()); assert_eq!(rebuilt, envelope, "hand-built COSE_Sign1 does not match envelope.hex"); - assert_eq!(envelope.len(), 663); + // 663 before the receipt committed to its output (SPEC §6.2), whose field + // the reference generation now carries. + assert_eq!(envelope.len(), 747); } /// The verifier direction is not vacuous: a single changed payload byte, or the right diff --git a/mesh/mesh-rs/crates/mesh-core/src/lib.rs b/mesh/mesh-rs/crates/mesh-core/src/lib.rs index 7f2ff66a4..3623a78b9 100644 --- a/mesh/mesh-rs/crates/mesh-core/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-core/src/lib.rs @@ -43,7 +43,7 @@ pub use journal::{FileJournal, Journal, JournalEntry, JournalRecord, MemoryJourn pub use presentation::Presentation; use mesh_engine::ServingEngine; use mesh_meter::{Meter, Session}; -use mesh_receipt::{MeshReceipt, ReceiptError, ReceiptLog}; +use mesh_receipt::{output_commitment, payload_digest, MeshReceipt, ReceiptError, ReceiptLog}; use mesh_router::{BackendSpec, Router}; use mesh_schema::{Energy, RouteDecision, ServeOutcome, ServeRequest}; @@ -175,6 +175,11 @@ pub struct Mesh<'m> { unresolved: Vec, /// Unix seconds, for expiry and time caveats. clock: fn() -> u64, + /// The key each receipt's output opening is drawn from + /// ([`Mesh::output_opening`]), itself drawn from the steward key, so a + /// steward that restarts can disclose the opening of any receipt it + /// sealed, without having stored one. + output_key: [u8; 32], } impl<'m> Mesh<'m> { @@ -184,6 +189,7 @@ impl<'m> Mesh<'m> { /// [`with_journal`](Self::with_journal) gives it a durable journal. pub fn new(meter: &'m dyn Meter, steward_sk: SigningKey, steward: impl Into) -> Self { let steward_did = Did::from_verifying_key(&steward_sk.verifying_key()); + let output_key = ContentId::of_bytes(&[b"mesh/1 output opening key\0".as_slice(), &steward_sk.to_bytes()].concat()).0; Self { router: Router::new(), engines: HashMap::new(), @@ -198,9 +204,19 @@ impl<'m> Mesh<'m> { spent_presentations: HashSet::new(), unresolved: Vec::new(), clock: system_clock, + output_key, } } + /// The opening of the output commitment in this steward's receipt for + /// `request_id` (SPEC §6.4): what the steward discloses, with the output, + /// for a party to check one receipt by [`MeshReceipt::opens`] or to + /// re-execute it ([`replay`]). One opening opens one request's receipt and + /// says nothing about any other. + pub fn output_opening(&self, request_id: &ContentId) -> [u8; 32] { + ContentId::of_bytes(&[b"mesh/1 output opening\0".as_slice(), &self.output_key, &request_id.0].concat()).0 + } + /// Record grant decisions and seals in `journal`, first re-establishing /// the spend and the receipt chain from `recorded`, the records it already /// holds, by replaying each through the gate (SPEC §2.2, JCP §4.11). Call @@ -406,10 +422,12 @@ impl<'m> Mesh<'m> { /// Serve one metered inference. See the module docs for the four phases. /// - /// `prompt` is the input token payload. It is passed to the engine but never - /// sealed into the receipt — the receipt records only the request's - /// content-addressed `prompt_hash` (SPEC §3.1). Pass `&[]` for engines that - /// do not consume a payload (e.g. the synthetic reference engine). + /// `prompt` is the input token payload. It is passed to the engine and never + /// sealed into the receipt in the clear: the receipt records the request's + /// content-addressed `prompt_hash` (SPEC §3.1), and binds the payload's + /// digest only inside the output commitment, under its opening (§6.4). + /// Pass `&[]` for engines that do not consume a payload (e.g. the synthetic + /// reference engine). pub fn serve( &mut self, req: &ServeRequest, @@ -578,6 +596,14 @@ impl<'m> Mesh<'m> { fidelity_met, energy, grant_breach, + // A generation commits to its tokens. A typed decision's answer is + // not carried by the engine interface, so there is nothing to + // commit to, and the field is absent rather than a commitment to an + // empty output that a decision never produced. + output_commitment: req + .decision + .is_none() + .then(|| output_commitment(&self.output_opening(&req.id()), &req.id(), &payload_digest(prompt), &served.tokens)), sequence: 0, // stamped by the log prev: None, // stamped by the log nonce, @@ -657,6 +683,80 @@ fn nonce_from(id: &ContentId) -> [u8; 8] { [b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]] } +/// What re-executing a sealed serve established (SPEC §6.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Replay { + /// The engine, run again on the request and prompt, produced the output + /// the receipt commits to. + Verified, + /// It produced another output than the one the steward disclosed, which + /// opens the receipt's commitment on the same prompt payload. + /// `first_difference` is the first token position at which the two part. + Diverged { first_difference: usize }, + /// Re-execution cannot settle it, and why. A replay never reports a + /// divergence it cannot attribute to the serve. + NotReplayable(&'static str), +} + +/// Re-execute a sealed serve on `engine` and check its output against the +/// receipt's commitment, under the `opening` the steward disclosed for it +/// ([`Mesh::output_opening`]). +/// +/// It settles something only where it can: the engine must be deterministic +/// ([`ServingEngine::deterministic`]), since a sampled generation is not +/// expected to repeat; it must be the engine the receipt names, by backend, +/// recipe and, where the receipt sealed one, weights; the request must be the +/// one the receipt was sealed for; and the receipt must commit to an output. +/// Otherwise the answer is [`Replay::NotReplayable`], with the reason. +/// +/// `prompt` is the payload to re-run on. The request's `prompt_hash` +/// addresses the prompt's bytes, not the token ids an engine is handed, so it +/// cannot confirm the payload. The commitment binds the payload's digest. +/// +/// `claimed` is the output the steward disclosed. A re-run that opens the +/// commitment is [`Replay::Verified`]. One that does not is a divergence only +/// when `claimed` opens it on the same payload: that confirms the opening, +/// the payload and the claim together, so what remains is the serve. +/// Otherwise the mismatch might be a wrong payload or opening rather than the +/// serve, and the replay says it cannot settle it. +pub fn replay( + receipt: &MeshReceipt, + request: &ServeRequest, + prompt: &[u32], + opening: &[u8; 32], + engine: &mut dyn ServingEngine, + claimed: Option<&[u32]>, +) -> Replay { + if receipt.output_commitment.is_none() { + return Replay::NotReplayable("the receipt commits to no output"); + } + if request.id() != receipt.request_id { + return Replay::NotReplayable("not the request the receipt was sealed for"); + } + if engine.backend_id() != receipt.backend || engine.recipe_id() != receipt.recipe { + return Replay::NotReplayable("not the backend and recipe the receipt names"); + } + if receipt.weights_digest.is_some() && engine.weights_digest() != receipt.weights_digest { + return Replay::NotReplayable("not the weights the receipt names"); + } + if !engine.deterministic() { + return Replay::NotReplayable("the engine is not deterministic, so a re-run is not expected to repeat"); + } + let payload = payload_digest(prompt); + let again = engine.serve(request, prompt).tokens; + if receipt.opens(opening, &payload, &again) { + return Replay::Verified; + } + match claimed.filter(|c| receipt.opens(opening, &payload, c)) { + Some(c) => Replay::Diverged { + first_difference: c.iter().zip(&again).position(|(a, b)| a != b).unwrap_or(c.len().min(again.len())), + }, + None => Replay::NotReplayable( + "no disclosed output opens the commitment on this payload and opening, so a mismatch cannot be told from a wrong input", + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1540,4 +1640,221 @@ mod tests { added.sort(); assert_eq!(added, vec!["precision", "weights_digest"]); } + + // ── the output, bound and re-executed (SPEC §6.4) ─────────────────────── + + /// A deterministic engine whose tokens are the reference engine's, each + /// shifted by one from position `from` on: the same engine identity, + /// another output. + struct Drifting { + inner: ReferenceEngine, + from: usize, + } + impl ServingEngine for Drifting { + fn backend_id(&self) -> BackendId { + self.inner.backend_id() + } + fn recipe_id(&self) -> String { + self.inner.recipe_id() + } + fn fidelity(&self) -> Fidelity { + self.inner.fidelity() + } + fn est_uj_per_token(&self) -> u64 { + self.inner.est_uj_per_token() + } + fn deterministic(&self) -> bool { + true + } + fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served { + let mut s = self.inner.serve(req, prompt); + for t in s.tokens.iter_mut().skip(self.from) { + *t = t.wrapping_add(1); + } + s + } + } + + fn tiny() -> ReferenceEngine { + ReferenceEngine::new("tiny", "ssm-1b", Fidelity::F1, 50_000, 256) + } + + /// A generation's receipt commits to its output: the tokens the serve + /// returned open it under the steward's opening, and nothing else does. + /// The receipt used to bind what was asked and what it cost, and nothing + /// about what came back. + #[test] + fn a_generation_commits_to_its_output() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter); + let req = request(16, Fidelity::F1, 5_000_000); + let resp = mesh.serve(&req, &[], None).unwrap(); + let opening = mesh.output_opening(&req.id()); + assert!(resp.receipt.output_commitment.is_some()); + let payload = payload_digest(&[]); + assert!(resp.receipt.opens(&opening, &payload, &resp.tokens), "the tokens served open it"); + + let mut other = resp.tokens.clone(); + other[3] ^= 1; + assert!(!resp.receipt.opens(&opening, &payload, &other), "another output does not"); + assert!(!resp.receipt.opens(&[0u8; 32], &payload, &resp.tokens), "nor does another opening"); + assert!(!resp.receipt.opens(&opening, &payload_digest(&[0]), &resp.tokens), "nor another payload"); + assert_ne!( + mesh.output_opening(&request(17, Fidelity::F1, 5_000_000).id()), + opening, + "an opening opens one request's receipt" + ); + + // Sealed: it is in the signed core, so the envelope binds it. + let wire = resp.receipt.to_jcr1_wire(mesh.log.steward()); + assert_eq!(wire["output_commitment"], resp.receipt.output_commitment.unwrap().to_hex().as_str()); + resp.receipt.verify_envelope(&resp.envelope, mesh.log.steward(), &mesh.log.verifying_key()).unwrap(); + } + + /// A typed decision's answer is not carried, so its receipt commits to + /// nothing rather than to an empty output it never produced. + #[test] + fn a_typed_decision_commits_to_no_output() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter); + let mut req = request(16, Fidelity::F1, 5_000_000); + req.decision = Some(mesh_schema::DecisionShape { questions: 2, options_scored: 5, schema_tokens: 3 }); + let resp = mesh.serve(&req, &[], None).unwrap(); + assert_eq!(resp.receipt.output_commitment, None); + assert!(!resp.receipt.to_jcr1_wire(mesh.log.steward()).as_object().unwrap().contains_key("output_commitment")); + } + + /// A sealed serve from a deterministic engine re-executes to the output + /// its receipt commits to, and one that does not is a divergence, located + /// at the first token where it parts from the disclosed output. + #[test] + fn a_deterministic_serve_replays_to_its_commitment() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter); + let req = request(16, Fidelity::F1, 5_000_000); + let resp = mesh.serve(&req, &[], None).unwrap(); + let opening = mesh.output_opening(&req.id()); + + assert_eq!(replay(&resp.receipt, &req, &[], &opening, &mut tiny(), None), Replay::Verified); + + let mut drifting = Drifting { inner: tiny(), from: 5 }; + assert_eq!( + replay(&resp.receipt, &req, &[], &opening, &mut drifting, Some(&resp.tokens)), + Replay::Diverged { first_difference: 5 } + ); + } + + /// A mismatch is the serve's only once the disclosed output opens the + /// commitment on the payload the replay ran: otherwise it may be the + /// replayer's input that is wrong, and a replay says it cannot settle it. + #[test] + fn a_replay_attributes_no_mismatch_its_inputs_could_explain() { + /// Deterministic, and reads its payload: each token moves by the sum + /// of the prompt's. + struct Reading(ReferenceEngine); + impl ServingEngine for Reading { + fn backend_id(&self) -> BackendId { + self.0.backend_id() + } + fn recipe_id(&self) -> String { + self.0.recipe_id() + } + fn fidelity(&self) -> Fidelity { + self.0.fidelity() + } + fn est_uj_per_token(&self) -> u64 { + self.0.est_uj_per_token() + } + fn deterministic(&self) -> bool { + true + } + fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served { + let shift = prompt.iter().fold(0u32, |a, t| a.wrapping_add(*t)); + let mut s = self.0.serve(req, prompt); + for t in &mut s.tokens { + *t = t.wrapping_add(shift); + } + s + } + } + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter); + let req = request(16, Fidelity::F1, 5_000_000); + // The reference engine ignores the payload, so what it served is what + // `Reading` serves on a payload summing to zero. + let resp = mesh.serve(&req, &[0], None).unwrap(); + let opening = mesh.output_opening(&req.id()); + let not = |r: Replay| match r { + Replay::NotReplayable(why) => why, + other => panic!("settled what it could not: {other:?}"), + }; + + assert_eq!(replay(&resp.receipt, &req, &[0], &opening, &mut Reading(tiny()), Some(&resp.tokens)), Replay::Verified); + // Another payload: the honest output, disclosed, is no evidence against + // the serve, because it does not open the commitment on that payload. + let wrong = replay(&resp.receipt, &req, &[5], &opening, &mut Reading(tiny()), Some(&resp.tokens)); + assert!(not(wrong).contains("wrong input")); + // Nor is anything without a disclosed output, or under another opening. + let mut drifting = Drifting { inner: tiny(), from: 5 }; + assert!(not(replay(&resp.receipt, &req, &[0], &opening, &mut drifting, None)).contains("wrong input")); + let mut drifting = Drifting { inner: tiny(), from: 5 }; + assert!(not(replay(&resp.receipt, &req, &[0], &opening, &mut drifting, Some(&[1, 2, 3]))).contains("wrong input")); + assert!(not(replay(&resp.receipt, &req, &[0], &[7u8; 32], &mut tiny(), Some(&resp.tokens))).contains("wrong input")); + } + + /// A replay settles nothing it cannot attribute to the serve, and says why. + #[test] + fn a_replay_refuses_what_it_cannot_settle() { + struct Sampling(ReferenceEngine); + impl ServingEngine for Sampling { + fn backend_id(&self) -> BackendId { + self.0.backend_id() + } + fn recipe_id(&self) -> String { + self.0.recipe_id() + } + fn fidelity(&self) -> Fidelity { + self.0.fidelity() + } + fn est_uj_per_token(&self) -> u64 { + self.0.est_uj_per_token() + } + fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> mesh_engine::Served { + self.0.serve(req, prompt) + } + } + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let mut mesh = mesh_with(&meter); + let req = request(16, Fidelity::F1, 5_000_000); + let resp = mesh.serve(&req, &[], None).unwrap(); + let opening = mesh.output_opening(&req.id()); + let not = |r: Replay| match r { + Replay::NotReplayable(why) => why, + other => panic!("settled what it could not: {other:?}"), + }; + + assert!(not(replay(&resp.receipt, &req, &[], &opening, &mut Sampling(tiny()), None)).contains("not deterministic")); + let other_req = request(17, Fidelity::F1, 5_000_000); + assert!(not(replay(&resp.receipt, &other_req, &[], &opening, &mut tiny(), None)).contains("request")); + let mut other_engine = ReferenceEngine::new("frontier", "wire-rpc", Fidelity::F4, 5_000_000, 256); + assert!(not(replay(&resp.receipt, &req, &[], &opening, &mut other_engine, None)).contains("backend")); + let mut named = resp.receipt.clone(); + named.weights_digest = Some(ContentId::of_bytes(b"other weights")); + assert!(not(replay(&named, &req, &[], &opening, &mut tiny(), None)).contains("weights")); + let mut bare = resp.receipt.clone(); + bare.output_commitment = None; + assert!(not(replay(&bare, &req, &[], &opening, &mut tiny(), None)).contains("no output")); + } + + /// The opening is drawn from the steward key, so a steward that restarts + /// can disclose the opening of a receipt it sealed before, without having + /// stored it. + #[test] + fn a_restarted_steward_can_disclose_an_earlier_opening() { + let meter = SteppingMeter::new("test-rapl", Provenance::HwShunt, 1_000); + let req = request(16, Fidelity::F1, 5_000_000); + let resp = mesh_with(&meter).serve(&req, &[], None).unwrap(); + let again = mesh_with(&meter); + assert!(resp.receipt.opens(&again.output_opening(&req.id()), &payload_digest(&[]), &resp.tokens)); + } } diff --git a/mesh/mesh-rs/crates/mesh-engine/src/lib.rs b/mesh/mesh-rs/crates/mesh-engine/src/lib.rs index f3dad7df9..9169a2639 100644 --- a/mesh/mesh-rs/crates/mesh-engine/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-engine/src/lib.rs @@ -83,11 +83,21 @@ pub trait ServingEngine { fn weights_digest(&self) -> Option { None } + /// Whether serving the same request and prompt again produces the same + /// tokens. A sealed serve from a deterministic engine can be re-executed + /// and checked against its receipt's output commitment + /// (`mesh_core::replay`). `false`, the default, for any engine that + /// samples: re-running it settles nothing, and a replay says so rather + /// than reporting a divergence that is only the sampler. + fn deterministic(&self) -> bool { + false + } /// Serve the request. `prompt` is the input token payload — passed - /// alongside the request, never sealed into the receipt (the receipt records - /// only the content-addressed `prompt_hash`, §3.1). Implementations MUST do - /// work proportional to the tokens produced so the surrounding meter reads a - /// real span. + /// alongside the request, never sealed into the receipt in the clear (the + /// receipt records the content-addressed `prompt_hash`, §3.1, and the + /// payload's digest only inside its output commitment, §6.4). + /// Implementations MUST do work proportional to the tokens produced so the + /// surrounding meter reads a real span. fn serve(&mut self, req: &ServeRequest, prompt: &[u32]) -> Served; } @@ -237,6 +247,13 @@ impl ServingEngine for ReferenceEngine { self.weights_digest } + /// Its tokens are a pure function of the request: a SplitMix64 stream + /// seeded from `prompt_hash` and `max_tokens`. Its cache changes the + /// cache-hit count, never the tokens. + fn deterministic(&self) -> bool { + true + } + fn serve(&mut self, req: &ServeRequest, _prompt: &[u32]) -> Served { // The reference engine is synthetic: its output is a deterministic // function of the request's content id, independent of the prompt diff --git a/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs b/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs index 596536eb0..eb6f99b73 100644 --- a/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-receipt/src/lib.rs @@ -118,6 +118,22 @@ pub struct MeshReceipt { /// the serve rode on no grant. #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_breach: Option, + /// A commitment to the output the serve produced (SPEC §6.4), for a + /// generation: [`output_commitment`] over the output tokens, under an + /// opening the steward holds and discloses for this receipt alone. + /// + /// Without it the receipt bound what was asked (`request_id`, which + /// content-addresses the prompt) and what it cost, and nothing about what + /// came back, so no one could later show which answer a receipt was for. + /// It is a commitment, not a hash of the tokens: a hash of a short output + /// (a one-token answer) can be reversed by trying every output, and a + /// receipt is shown to parties who were not owed the answer. With the + /// opening, anyone checks a disclosed output against the receipt + /// ([`MeshReceipt::opens`]); without it the commitment says nothing about + /// the output. `None`, skipped on the wire, for a typed decision (whose + /// answer is not carried) and for receipts sealed before the field existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_commitment: Option, /// Monotonic sequence in the steward's receipt log. pub sequence: u64, /// Content id of the previous envelope (hash chain); `None` at genesis. @@ -224,6 +240,11 @@ impl MeshReceipt { if let Some(b) = &self.grant_breach { obj.insert("grant_breach".into(), json!(b)); } + // Emitted only for a serve that committed to its output, so a receipt + // sealed before the field existed keeps its bytes. + if let Some(c) = &self.output_commitment { + obj.insert("output_commitment".into(), json!(c.to_hex())); + } obj.insert("sequence".into(), json!(self.sequence)); if let Some(p) = &self.prev { obj.insert("prev".into(), json!(p.to_hex())); @@ -233,6 +254,15 @@ impl MeshReceipt { serde_json::Value::Object(obj) } + /// Whether `tokens` are the output this receipt committed to, served on + /// the prompt payload whose [`payload_digest`] is `payload`, under + /// `opening`: the check a party runs on what the steward disclosed for + /// this receipt (SPEC §6.4). `false` for a receipt that commits to no + /// output. + pub fn opens(&self, opening: &[u8; 32], payload: &ContentId, tokens: &[u32]) -> bool { + self.output_commitment == Some(output_commitment(opening, &self.request_id, payload, tokens)) + } + /// The canonical signed-core bytes (JCR-1 det-CBOR). Validates the /// provenance ↔ measured coupling first, so a dishonest receipt cannot be /// turned into bytes a verifier would accept. @@ -263,6 +293,52 @@ impl MeshReceipt { } } +/// The domain an output commitment is drawn in, so that it can equal no other +/// content address the family computes. +const OUTPUT_DOMAIN: &[u8] = b"mesh/1 output commitment\0"; + +/// The domain a prompt payload's digest is drawn in. +const PAYLOAD_DOMAIN: &[u8] = b"mesh/1 prompt payload\0"; + +/// The digest of the prompt payload an engine was handed: the content +/// address of the domain and the token ids as little-endian `u32`s (SPEC +/// §6.4). +/// +/// The request's `prompt_hash` addresses the prompt's bytes, and the engine is +/// handed token ids. Nothing ties the two together without the tokenizer, so +/// the output commitment binds this digest as well. +pub fn payload_digest(prompt: &[u32]) -> ContentId { + let mut bytes = Vec::with_capacity(PAYLOAD_DOMAIN.len() + prompt.len() * 4); + bytes.extend_from_slice(PAYLOAD_DOMAIN); + for t in prompt { + bytes.extend_from_slice(&t.to_le_bytes()); + } + ContentId::of_bytes(&bytes) +} + +/// Commit to a generation's output `tokens`, for the request `request_id` +/// served on the prompt payload whose [`payload_digest`] is `payload`, under +/// `opening` (SPEC §6.4): the content address of the domain, the opening, the +/// request id, the payload digest and the tokens as little-endian `u32`s. +/// +/// BLAKE3 with a secret 32-byte prefix is a commitment: without the opening +/// it reveals nothing about the tokens, however few they are, and no one can +/// find a second output under the same opening. Binding the request id means +/// an opening disclosed for one receipt opens no other. Binding the payload +/// means an output that opens it was served on that payload, so a replay can +/// tell a different payload from a different output. +pub fn output_commitment(opening: &[u8; 32], request_id: &ContentId, payload: &ContentId, tokens: &[u32]) -> ContentId { + let mut bytes = Vec::with_capacity(OUTPUT_DOMAIN.len() + 96 + tokens.len() * 4); + bytes.extend_from_slice(OUTPUT_DOMAIN); + bytes.extend_from_slice(opening); + bytes.extend_from_slice(&request_id.0); + bytes.extend_from_slice(&payload.0); + for t in tokens { + bytes.extend_from_slice(&t.to_le_bytes()); + } + ContentId::of_bytes(&bytes) +} + /// A steward's hash-chained receipt log. Each [`append`](ReceiptLog::append) /// stamps the next sequence and the prior envelope's content id, seals the /// receipt, and advances the head — so the log is tamper-evident end to end. @@ -386,6 +462,7 @@ mod tests { fidelity_met: true, energy, grant_breach: None, + output_commitment: None, sequence: 0, prev: None, nonce: [1, 2, 3, 4, 5, 6, 7, 8], @@ -564,4 +641,24 @@ mod tests { assert!(r.verify_envelope(env, log.steward(), &vk).is_ok()); } } + + /// The commitment binds the request, the opening, the prompt payload and + /// every token: change any one and it is another commitment, so an opening + /// disclosed for one receipt opens no other, and no other output, nor the + /// same output on another payload, opens it. + #[test] + fn an_output_commitment_binds_request_opening_payload_and_tokens() { + let (a, b) = (ContentId::of_bytes(b"request a"), ContentId::of_bytes(b"request b")); + let o = [3u8; 32]; + let p = payload_digest(&[1, 2]); + let t = [7u32, 9, 11]; + let c = output_commitment(&o, &a, &p, &t); + assert_ne!(c, output_commitment(&o, &b, &p, &t), "the request"); + assert_ne!(c, output_commitment(&[4u8; 32], &a, &p, &t), "the opening"); + assert_ne!(c, output_commitment(&o, &a, &payload_digest(&[1, 3]), &t), "the payload"); + assert_ne!(c, output_commitment(&o, &a, &p, &[7, 9, 12]), "a token"); + assert_ne!(c, output_commitment(&o, &a, &p, &[7, 9]), "the length"); + assert_eq!(c, output_commitment(&o, &a, &p, &t), "and nothing else"); + assert_ne!(payload_digest(&[]), payload_digest(&[0]), "an empty payload is not a zero token"); + } } -- 2.54.0 (Apple Git-157)