Skip to main content

WAI Extension: JWP Receipt Chain

Status: Draft. Sub-spec of WAI v1.0. Mirrors the Rust reference impl at https://github.com/Transaction-Science/open-standards/tree/main/wai/extensions/jwp-receipts.

Keywords MUST, MUST NOT, SHOULD, MAY, REQUIRED, OPTIONAL are RFC 2119 / RFC 8174.


1. Scope and model

This extension defines a JWP-shaped receipt chain that attests per-object delivery of WAI envelope objects through a chain of relays. A receipt names the bytes a sink received, the publisher that produced them, the energy spent producing them, and the chain of relays the bytes traversed — all under a single Ed25519 signature per group.

In scope:

Out of scope:

Relationship to WAI SPEC.md:


2. Wire format

2.1. Per-object content hash

For each WAI envelope object the publisher emits, the publisher MUST compute a 32-byte BLAKE3-256 content hash:

content_hash = BLAKE3(domain || envelope_bytes)
domain       = "moq-jwp:object\x01"

envelope_bytes is the full WAI envelope per WAI SPEC.md §2 (the WAI1 magic, the manifest-length prefix, the manifest, the payload- length prefix, the payload — concatenated, with no insertion or reframing). The domain string is appended as a domain separator to prevent cross-protocol hash collisions.

2.2. Per-group Merkle root

A group is an ordered list of one or more objects under a single publisher key. The publisher MUST build a BLAKE3 binary Merkle tree over the object content hashes in their group order. Leaves and internal nodes use distinct domain separators:

leaf(content_hash)        = BLAKE3("moq-jwp:merkle-leaf\x01" || content_hash)
node(left, right)         = BLAKE3("moq-jwp:merkle-node\x01" || left || right)

When the leaf count at any level is odd, the publisher MUST duplicate the final leaf to obtain an even pair. The root is the single hash at the top of the tree. The empty-group root is the 32-byte all-zero value 0x00..00.

2.3. Per-group Ed25519 signature

The publisher MUST sign the canonical signing payload with an Ed25519 key per RFC 8032 (Pure Ed25519, no prehash). The signing payload is the big-endian concatenation:

signing_payload =
    "moq-jwp:group-sign\x01"          // domain
 || u64_be(track_alias)                // optional carrier-scoped id (0 if unused)
 || u64_be(group_id)                   // monotonic per-track group counter
 || merkle_root[32]                    // §2.2
 || u64_be(leaf_count)                 // number of objects in the group
 || u64_be(total_joules_micro)         // sum of microjoules across the group
 || u32_be(len(group_start)) || group_start_utf8   // RFC 3339 start timestamp
 || u32_be(len(group_end))   || group_end_utf8     // RFC 3339 end timestamp
 || publisher_key[32]                  // Ed25519 verifying key (RFC 8032)
 || u32_be(len(publisher_id)) || publisher_id_utf8 // bound identity string

The signature is exactly 64 bytes. Implementations MUST reject any group receipt whose signature length is not 64.

2.4. Receipt envelope (typed)

Two receipt kinds are defined: group (one per group) and object (one per object). Both are typed JSON; equivalent CBOR is permitted when the carrier mandates it, provided the field set and semantics are identical.

2.4.1. kind: "group"

Required fields:

fieldtypemeaning
kindstringMUST equal "group".
track_aliasu64Carrier-scoped track identifier; 0 if unused.
group_idu64Monotonic group counter under publisher_key.
root_hash32 B hex§2.2 Merkle root over object content hashes.
leaf_countu64Number of objects covered by root_hash.
joules_totalu64Sum of microjoules across the group’s objects.
group_startstringRFC 3339 timestamp of the first object.
group_endstringRFC 3339 timestamp of the last object.
signer_pubkey32 B hexEd25519 verifying key per RFC 8032.
signer_idstringBound identity string (DID, URN, or display).
sig64 B hexEd25519 signature over §2.3 payload.
parent_receipt_hash32 B hex or nullBLAKE3 of the previous group receipt’s canonical bytes under this publisher key; null for the first group.

2.4.2. kind: "object"

Required fields:

fieldtypemeaning
kindstringMUST equal "object".
track_aliasu64Mirrors the parent group receipt.
group_idu64Mirrors the parent group receipt.
object_idu64Monotonic from 0 within the group.
content_hash32 B hex§2.1 BLAKE3 over the WAI envelope bytes.
joules_microu64Microjoules consumed producing this object.
originstringDatacenter / origin label.
merkle_proofobjectInclusion proof: { "siblings": [32 B hex, …], "direction_bits": u64, "leaf_index": u64 }. direction_bits bit i is 1 iff the current node at level i is the right child.

2.5. Worked example

Manifest JSON (group receipt, formatted for readability — wire form serializes with no insignificant whitespace and preserves key insertion order):

{
  "kind": "group",
  "track_alias": 7,
  "group_id": 3,
  "root_hash": "5d2c7b1f9a04e2a16c83b3d774a09f12c44b65a3e8d11f88c2901a47b50d2c61",
  "leaf_count": 2,
  "joules_total": 250,
  "group_start": "2026-04-25T12:00:00Z",
  "group_end":   "2026-04-25T12:00:01Z",
  "signer_pubkey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
  "signer_id": "did:joule:publisher-1",
  "sig": "9f3a4d6b1c0e8742f1b6b94e7d1c2a85f5103e4c8b2d61a93f0e4b8f2c70a1d61f6a2b71d4eecbb0d7a2e90c4b1bf2c8a0e4d63b9101fa0e2c4d83b1f70e9a103",
  "parent_receipt_hash": null
}

Object receipt for the second object in that group:

{
  "kind": "object",
  "track_alias": 7,
  "group_id": 3,
  "object_id": 1,
  "content_hash": "1c3f4a2e88b0d77192aabd0c66f12345e89c7b41ab2cd35f7e80b912ad6c9012",
  "joules_micro": 150,
  "origin": "joule-cloud-us-west-2",
  "merkle_proof": {
    "siblings": [
      "a07b4d2c91f5e3110687f4b210ff8e1133acdb0741e3920c19acf8b3110d57e2"
    ],
    "direction_bits": 1,
    "leaf_index": 1
  }
}

The signature hex shown is illustrative; conformance is checked against the test vectors in §5.


3. Verifier algorithm

A conforming verifier MUST execute the following six steps in order for every group receipt and every object receipt it accepts. Steps match the algorithm style of WAI SPEC.md §4 (numbered, terminating).

  1. Parse and shape-check. Read the group receipt. If kind != "group", reject. If sig is not 64 bytes, reject. If signer_pubkey is not a valid 32-byte Ed25519 verifying key (RFC 8032), reject. If any required field from §2.4.1 is absent, reject.

  2. Verify the Ed25519 group signature. Construct the canonical signing payload per §2.3 from the receipt’s fields. Verify sig against signer_pubkey per RFC 8032 Pure Ed25519. If verification fails, reject. After this step, the verifier holds a verified root: the receipt’s root_hash field, now bound to signer_pubkey under signature.

  3. Check parent linkage. If the verifier maintains receipt-chain state for signer_pubkey, compute BLAKE3("moq-jwp:receipt-link\x01" || canonical_bytes(previous_group_receipt)) and compare to parent_receipt_hash. If they differ, reject (chain break). If no previous receipt is known and parent_receipt_hash is null, accept as the chain head. A non-null parent_receipt_hash without a corresponding previously-verified receipt is a recoverable condition: the verifier MAY request the missing receipt before accepting; it MUST NOT silently accept.

  4. Verify the object’s content hash. For each accompanying kind: "object" receipt: recompute content_hash per §2.1 over the received WAI envelope bytes and compare to the receipt’s content_hash field. If they differ, reject the object (the envelope was mutated in transit).

  5. Verify the Merkle inclusion proof. Compute the leaf as BLAKE3("moq-jwp:merkle-leaf\x01" || content_hash). Walk the merkle_proof.siblings list from level 0 upward; at each level i, combine the current value with the sibling using the direction_bits bit i (1 = current is right child, 0 = current is left child) and the moq-jwp:merkle-node\x01 domain. After walking all siblings, compare the resulting hash to the verified root from step 2. If they differ, reject.

  6. Accept. The object’s bytes are bound to signer_pubkey at (track_alias, group_id, object_id), with the publisher’s declared joules_micro and origin. The verifier MAY now persist the group receipt as the new chain head for signer_pubkey and dispatch the WAI envelope per WAI SPEC.md §4.

A verifier MUST treat any rejection in steps 1–5 as a hard failure for the object in question. A verifier MUST NOT accept a partial verification (e.g. valid Merkle proof against an unverified root).


4. Relay preservation requirements

A conforming relay forwards WAI envelopes from one or more publishers to one or more subscribers. To preserve the receipt chain across hops, a conforming relay:

4.1. MUST

4.2. MAY

4.3. MUST NOT


5. Test vectors

Conforming implementations MUST verify against the canonical test vector set generated by joulesperbit/crates/joule-moq-publisher/examples/jwp_receipts_vectors.rs. The generator emits five vectors covering the cases below. Each vector consists of: the WAI envelope bytes for every object in the group, the canonical group-receipt JSON, the canonical object-receipt JSON for every object, and a result.json declaring the expected verifier outcome (accept or one of the named rejection reasons).

5.1. Single-object group (vector v1_single_object)

One WAI envelope (wai.text.zstd, payload "hello" zstd-encoded). Group receipt with leaf_count = 1, parent_receipt_hash = null. Object receipt with object_id = 0, merkle_proof.siblings = [], direction_bits = 0, leaf_index = 0. Expected outcome: accept.

5.2. Multi-object group with proof verification (vector v2_multi_object)

Five WAI envelopes (wai.audio.opus, 20 ms frames). Group receipt with leaf_count = 5 (the publisher duplicates the trailing leaf internally when building the tree). Five object receipts, each with a non-empty merkle_proof. Expected outcome: accept for each object receipt.

5.3. Cross-hop preservation through 2 relays (vector v3_cross_hop)

Three publisher-issued group receipts in chain order G0, G1, G2 with parent_receipt_hash chaining each to its predecessor. Two relay hops are simulated: ingress bytes → relay A egress bytes → relay B egress bytes are emitted as three byte-equal copies. The verifier walks the chain in order; expected outcome: accept at every hop, and the chain head after G2 equals the BLAKE3 of G2’s canonical bytes per §3 step 3.

5.4. Invalid signature rejection (vector v4_bad_sig)

A well-formed group receipt with a single bit flipped in the sig field. Expected outcome: reject at §3 step 2 with reason signature_verify_failed.

5.5. Missing parent_receipt_hash rejection (vector v5_missing_parent)

Two group receipts G0, G1. G1 omits the parent_receipt_hash field entirely (rather than setting it to null or a hash). Expected outcome: reject at §3 step 1 with reason missing_required_field.

The generator writes vectors to joulesperbit/crates/joule-moq-publisher/examples/jwp_receipts_vectors/ as one directory per case. A conforming verifier MUST accept the accept cases bit-exactly and MUST reject the named cases at the declared step with the declared reason. The Rust reference verifier in inv-moq::receipt::ReceiptVerifier exercises all five vectors under cargo test --lib -p inv-moq.


6. Security considerations

6.1. Replay

A receipt without sequencing information is replayable: an attacker who captures group receipt G can re-present it to a downstream verifier and bind unrelated bytes to the publisher’s identity at a later time. This extension mitigates replay with two mechanisms:

6.2. Forgery

The signing payload (§2.3) is bound under Ed25519 (RFC 8032). At classical security levels, Ed25519 provides ~128-bit unforgeability under chosen-message attack (EUF-CMA). Forging a group receipt is equivalent to recovering the publisher’s private key or breaking Ed25519. Implementations MUST source signing keys from a CSPRNG and MUST NOT reuse keys across distinct publisher identities.

6.3. Cross-group confusion

The signing payload binds track_alias, group_id, and signer_pubkey under the same signature. An attacker who attempts to re-present a group receipt under a different (track_alias, group_id) tuple cannot do so without re-signing — the receipt’s fields are signature-covered, and any field mutation breaks verification at §3 step 2. The Merkle proof is similarly bound: leaf_index is verifier-checked during the proof walk, so an object’s proof cannot be transplanted to a different position within the same group.

6.4. Envelope mutation in transit

Any mutation of the WAI envelope bytes (manifest rewrite, payload re-frame, whitespace insertion) changes the §2.1 content hash and is detected at §3 step 4. Relays that re-canonicalize the manifest SHOULD be treated as broken: they break content-hash equality even when the semantic meaning is unchanged. WAI SPEC.md §2 already requires that the manifest be serialized with no insignificant whitespace and that encoders preserve key insertion order; receipt verification depends on relays honouring that requirement.

6.5. Quorum and multi-signer receipts

v1.0 of this extension is single-signer: every group receipt is signed by exactly one Ed25519 key. Multi-signer / quorum receipts (threshold Ed25519, BLS aggregate signatures) are explicitly out of scope for v1.0. A future minor revision MAY extend §2.3 with a multi-signer container; the v1.0 wire format remains a strict subset.

6.6. Out of scope