Testnet (every hash below is computed in production by the coordinator at close and recomputed by the CLI and WASM auditors; values for both testnet auctions matched, evidence/audit-rescan.md, evidence/testnet-genesis.md).
Hash function
Every sealForge hash is BLAKE2b (RFC 7693) with:- digest length 32 set in the parameter block (a native 32-byte digest, not a truncated BLAKE2b-512),
- no key and no salt,
- a 16-byte personalization string in the parameter block, which separates domains.
blake2b_simd 1.0.5, Params::new().hash_length(32).personal(P) (crates/sealforge-core/src/receipt.rs, crates/sealforge-core/src/merkle.rs). TypeScript: @noble/hashes/blake2b with { dkLen: 32, personalization: P } (packages/bid/src/memo.ts). All five personalizations are exactly 16 bytes, so no implementation-specific padding applies.
Domain table
codeis the 10 ASCII bytes of the auction code.refund_uais the UTF-8 bytes of the refund Unified Address as written in the memo (r bytes).saltis 32 random bytes.\0is a single0x00byte, padding the 15-character Merkle strings to 16.txidis the 32-byte transaction id in internal byte order (aszcash_primitivesTxIdholds it), not the byte-reversed display order (crates/sealforge-scan/src/lib.rsReceivedPayment::txid).- In sealed-reveal mode the leaf’s
amount_zatis the revealed price, and itscommitmentis the deposit memo’s price commitment (selection::included_bids_commit_reveal).
7K3QX9MZ2P, utest1refundexample, salt 01…01):
Output:
deposit_mode.bid_commitment in Appendix: test vectors (also pinned by receipt::tests::cross_language_vector).
Binding
A commitment binds a bid to one auction in two independent ways:- The auction code is inside the hash. A commitment computed for auction X does not open for auction Y.
- Each auction has its own escrow address from a fresh DKG and freshly sampled
nk,rivk(3). A payment counts only if it decrypts under that auction’s UIVK, so a payment made to one auction’s escrow can never be counted by another. Selection additionally rejects a memo whose code doesn’t match (invalid(wrong_auction)).
(block_height, txid, output_index) counts and the rest are invalid(duplicate_commitment) and refunded (selection::classify, test earliest_duplicate_commitment_wins_regardless_of_order). This stops a copied memo from displacing the original bid.
Why the 0x00 separators
refund_ua has variable length, so the encoding must make it impossible for two different (code, refund_ua, salt) (or …, price, salt) tuples to produce the same bytes. Today the code is fixed at 10 bytes and the salt (and price) are fixed-length at the end, so the input already parses one way. The separators keep it unambiguous without relying on that: they mark where each variable-length field ends if a later version changes a length. Separators cost two bytes and keep the TypeScript and Rust encoders trivially identical. The leaf preimage has no separators because every field in it is fixed-length.
Merkle tree
Construction (crates/sealforge-core/src/merkle.rs MerkleTree::from_leaves):
- Compute a leaf for every included bid:
validandunder_reservebids both go in, so every bidder can prove their bid was seen (result::compute_result). - Sort leaves ascending (bytewise) and reject duplicates (
DuplicateLeaf). The root therefore doesn’t depend on scan order (root_ignores_input_order). - Pad to the next power of two with all-zero 32-byte leaves. There is no odd-level case: every level has even width. An empty tree is one zero leaf.
- Hash pairs with the node personalization up to one
tree_root. - Published root =
BLAKE2b(sealforge_root1\0, leaf_count u64 LE ‖ tree_root).
tree_root with the same leaves plus explicit zero leaves. Binding leaf_count fixes how many leaves are real. The verifier also rejects index ≥ leaf_count and proofs whose sibling count isn’t log2(next_pow2(leaf_count)) (verify_proof; tests padding_cannot_be_proven_as_a_bid, tampering_fails).
Inclusion proof (InclusionProof, JSON with hex siblings): leaf_count, index (position in the sorted leaves), siblings from leaf level up to, but excluding, the tree root.
Vectors for a leaf, a node, and 2- and 3-leaf roots are in Appendix: test vectors. The vector generator recomputes the node and root from this page’s table, independently of MerkleTree, and asserts they agree (crates/sealforge-core/tests/support/vectors.rs).
Salt
32 bytes from the browser CSPRNG (crypto.getRandomValues, packages/bid/src/receipt.ts prepareBid, prepareSealedBid). The salt stays in the receipt in the browser and is never sent to the coordinator. In sealed-reveal mode the bidder publishes it in the reveal memo after the bidding close. In deposit mode it is never published. The bidder uses it to check inclusion (/verify).
Rust and TypeScript parity
@sealforge/bid reimplements the two commitments, both memos and the ZIP 321 URI. Parity is checked byte for byte against one file:
crates/sealforge-core/tests/fixtures/vectors.jsonis produced bycargo run -p sealforge-core --example vectors(generator:crates/sealforge-core/tests/support/vectors.rs).- Rust pins it:
crates/sealforge-core/tests/vectors.rs, plus the older inline vectorsreceipt::tests::cross_language_vectorandprice_commitment_vector. - TypeScript checks the same file:
packages/bid/src/bid.test.ts, “shared vectors file”. - Both run in CI (
.github/workflows/ci.yml:cargo test --workspaceandpnpm test).
What is not in the commitment
- The network. Not hashed. Refund addresses are network-encoded (
utest1…,u1…), and the coordinator rejects a refund UA for the wrong network (validate::shielded_ua). An escrow exists on one network only, so a payment can’t count on another network. - The coordinator instance. Not hashed. Two coordinators can issue the same auction code (2.3), but their escrows differ, and a bid only counts at the escrow it paid. A commitment reused across two such auctions would open for both. That needs the same code, refund UA and salt, so it’s only the bidder’s own choice to replay.
Note-level randomness is not ours
sealForge never chooses note randomness. Bidder payments and escrow spends are built with librustzcash’s builder and the PCZT roles, which draw it from the OS CSPRNG passed in by sealForge:
sealForge adds no application-specific domain to note construction. Note commitment randomness (
rcm), ψ and esk are derived inside orchard 0.16.0 from the note’s rseed (RandomSeed in src/note.rs). Upstream documents rcm_v2 against Zcash Protocol Specification §4.7.3 “Sending Notes (Orchard)” and rcm_v3 (quantum-recoverable) against ZIP 2005. Which derivation a note uses is orchard’s decision, not sealForge’s, and this page does not restate either.