Skip to main content
Status: 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.
Rust: 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

  • code is the 10 ASCII bytes of the auction code. refund_ua is the UTF-8 bytes of the refund Unified Address as written in the memo (r bytes). salt is 32 random bytes.
  • \0 is a single 0x00 byte, padding the 15-character Merkle strings to 16.
  • txid is the 32-byte transaction id in internal byte order (as zcash_primitives TxId holds it), not the byte-reversed display order (crates/sealforge-scan/src/lib.rs ReceivedPayment::txid).
  • In sealed-reveal mode the leaf’s amount_zat is the revealed price, and its commitment is the deposit memo’s price commitment (selection::included_bids_commit_reveal).
Byte layout of the deposit-mode commitment input, with the vector inputs (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:
  1. The auction code is inside the hash. A commitment computed for auction X does not open for auction Y.
  2. 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)).
Duplicate commitments. If several payments carry the same commitment, the earliest by (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):
  1. Compute a leaf for every included bid: valid and under_reserve bids both go in, so every bidder can prove their bid was seen (result::compute_result).
  2. Sort leaves ascending (bytewise) and reject duplicates (DuplicateLeaf). The root therefore doesn’t depend on scan order (root_ignores_input_order).
  3. 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.
  4. Hash pairs with the node personalization up to one tree_root.
  5. Published root = BLAKE2b(sealforge_root1\0, leaf_count u64 LE ‖ tree_root).
Why the leaf count is in the root. Padding leaves are zeros, so without the count a prover could present a padding position as a bid, and a tree of n real leaves would share its 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.json is produced by cargo 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 vectors receipt::tests::cross_language_vector and price_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 --workspace and pnpm test).
The Merkle tree is Rust-only. The browser uses the Rust auditor compiled to WASM, so there is no second Merkle implementation to keep in sync.

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.
Whether to bind both anyway is listed in 11 Open questions.

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.