The CommitmentTree — Sinsemilla Commitment Anchors

The CommitmentTree is GroveDB's bridge between authenticated storage and zero-knowledge proof systems. It combines a BulkAppendTree (Chapter 14) for efficient chunk-compacted data storage with a Sinsemilla frontier in the data namespace for ZK-compatible anchors. Like MmrTree and BulkAppendTree, it has no child Merk — the combined root hash flows as the Merk child hash. Both the BulkAppendTree entries and the Sinsemilla frontier live in the data namespace.

This chapter covers the Sinsemilla hash function and why it matters for zero-knowledge circuits, the frontier data structure and its compact serialization, the dual-namespace storage architecture, the GroveDB operations, batch preprocessing, client-side witness generation, and how proofs work.

Why a ZK-Friendly Tree?

GroveDB's standard trees use Blake3 hashing. Blake3 is fast in software, but expensive inside zero-knowledge circuits. When a spender needs to prove "I know a note at position P in the commitment tree" without revealing P, they must evaluate the Merkle hash function 32 times (once per tree level) inside a ZK circuit.

Sinsemilla (specified in ZIP-244 for the Zcash Orchard protocol) is designed for exactly this use case — it provides efficient in-circuit hashing over the Pallas elliptic curve, one half of the Pasta curve cycle used by the Halo 2 proof system.

PropertyBlake3Sinsemilla
Circuit cost~25,000 constraints per hash~800 constraints per hash
Software speedVery fast (~2 GB/s)Slow (~10,000 hashes/s)
Algebraic structureNone (bitwise)Pallas curve point operations
Primary purposeGeneral hashing, Merkle treesIn-circuit Merkle proofs
Used byGroveDB Merk trees, MMR, BulkOrchard shielded protocol
Output size32 bytes32 bytes (Pallas field element)

The CommitmentTree uses Sinsemilla for the Merkle tree that ZK circuits reason about, while still using Blake3 for the GroveDB Merk hierarchy above it. Items inserted into the tree are stored via a BulkAppendTree in the data namespace (chunk-compacted, retrievable by position) and simultaneously appended to the Sinsemilla frontier (producing a ZK-provable anchor).

The Data-Namespace Architecture

The CommitmentTree stores all data in the data namespace at the same subtree path. Like MmrTree and BulkAppendTree, it has no child Merk (no root_key field — the type-specific root flows as the Merk child hash). The BulkAppendTree entries and the Sinsemilla frontier coexist in the data namespace using distinct key prefixes:

┌──────────────────────────────────────────────────────────────┐
│                       CommitmentTree                          │
│                                                               │
│  ┌─────────────────────────────────────────────────────────┐  │
│  │  Data Namespace                                         │  │
│  │                                                         │  │
│  │  BulkAppendTree storage (Chapter 14):                   │  │
│  │    Buffer entries → chunk blobs → chunk MMR             │  │
│  │    value = cmx (32) || rho (32) || ciphertext (216)     │  │
│  │                                                         │  │
│  │  Sinsemilla Frontier (~1KB):                            │  │
│  │    key: b"__ct_data__" (COMMITMENT_TREE_DATA_KEY)       │  │
│  │    Depth-32 incremental Merkle tree                     │  │
│  │    Stores only the rightmost path (leaf + ommers)       │  │
│  │    O(1) append, O(1) root computation                   │  │
│  │    Produces Orchard-compatible Anchor for ZK proofs     │  │
│  └─────────────────────────────────────────────────────────┘  │
│                                                               │
│  sinsemilla_root embedded in Element bytes                    │
│    → flows through Merk value_hash → GroveDB state root      │
└──────────────────────────────────────────────────────────────┘

Why two structures? The BulkAppendTree provides efficient, chunk-compacted storage and retrieval for potentially millions of encrypted notes. The Sinsemilla frontier provides ZK-compatible anchors that can be proved inside a Halo 2 circuit. Both are updated in lockstep on every append.

Compare with the other non-standard tree types:

CommitmentTreeMmrTreeBulkAppendTree
Child MerkNoNoNo
Data namespaceBulkAppendTree entries + frontierMMR nodesBuffer + chunks + MMR
Aux namespace
Items queryableVia V1 proofsVia V1 proofsVia V1 proofs
Hash functionSinsemilla + Blake3Blake3Blake3

The Sinsemilla Frontier

The frontier is a depth-32 incremental Merkle tree implemented by the incrementalmerkletree crate's Frontier<MerkleHashOrchard, 32> type. Instead of storing all 2^32 possible leaves, it stores only the information needed to append the next leaf and compute the current root: the rightmost leaf and its ommers (sibling hashes needed for root computation).

                         root (level 32)
                        /               \
                      ...               ...
                     /                     \
                  (level 2)             (level 2)
                  /     \               /     \
              (level 1) (level 1)   (level 1)  ?
              /    \    /    \      /    \
             L0    L1  L2    L3   L4    ?     ← frontier stores L4
                                              + ommers at levels
                                              where left sibling exists

The frontier stores:

  • leaf: the most recently appended value (a Pallas field element)
  • ommers: the left-sibling hashes at each level where the frontier path goes right (at most 32 ommers for a depth-32 tree)
  • position: the 0-indexed position of the leaf

Key properties:

  • O(1) append: insert a new leaf, update ommers, recompute root
  • O(1) root: traverse the stored ommers from leaf to root
  • ~1KB constant size: regardless of how many leaves have been appended
  • Deterministic: two frontiers with the same sequence of appends produce the same root

The EMPTY_SINSEMILLA_ROOT constant is the root of an empty depth-32 tree, precomputed as MerkleHashOrchard::empty_root(Level::from(32)).to_bytes():

0xae2935f1dfd8a24aed7c70df7de3a668eb7a49b1319880dde2bbd9031ae5d82f

How Appending Works — The Ommer Cascade

When a new commitment is appended at position N, the number of ommers that must be updated equals trailing_ones(N) — the number of trailing 1-bits in N's binary representation. This is the same pattern as the MMR merge cascade (§13.4), but operating on ommers rather than peaks.

Worked example — appending 4 leaves:

Position 0 (binary: 0, trailing_ones: 0):
  frontier = { leaf: L0, ommers: [], position: 0 }
  Sinsemilla hashes: 32 (root computation) + 0 (no ommer merges) = 32

Position 1 (binary: 1, trailing_ones: 0 of PREVIOUS position 0):
  Before: position 0 has trailing_ones = 0
  frontier = { leaf: L1, ommers: [H(L0,L1) at level 1], position: 1 }
  Sinsemilla hashes: 32 + 0 = 32

Position 2 (binary: 10, trailing_ones: 0 of PREVIOUS position 1):
  Before: position 1 has trailing_ones = 1
  frontier = { leaf: L2, ommers: [level1_hash], position: 2 }
  Sinsemilla hashes: 32 + 1 = 33

Position 3 (binary: 11, trailing_ones: 0 of PREVIOUS position 2):
  Before: position 2 has trailing_ones = 0
  frontier = { leaf: L3, ommers: [level1_hash, level2_hash], position: 3 }
  Sinsemilla hashes: 32 + 0 = 32

The total Sinsemilla hashes per append is:

32 (root computation always traverses all 32 levels)
+ trailing_ones(current_position)  (ommer cascade)

On average, trailing_ones is ~1 (geometric distribution), so the average cost is ~33 Sinsemilla hashes per append. The worst case (at position 2^32 - 1, where all bits are 1) is 64 hashes.

The Frontier Serialization Format

The frontier is stored in data storage at key b"__ct_data__". The wire format is:

┌──────────────────────────────────────────────────────────────────┐
│ has_frontier: u8                                                  │
│   0x00 → empty tree (no more fields)                             │
│   0x01 → non-empty (fields follow)                               │
├──────────────────────────────────────────────────────────────────┤
│ position: u64 BE (8 bytes)      — 0-indexed leaf position        │
├──────────────────────────────────────────────────────────────────┤
│ leaf: [u8; 32]                  — Pallas field element bytes     │
├──────────────────────────────────────────────────────────────────┤
│ ommer_count: u8                 — number of ommers (0..=32)      │
├──────────────────────────────────────────────────────────────────┤
│ ommers: [ommer_count × 32 bytes] — Pallas field elements        │
└──────────────────────────────────────────────────────────────────┘

Size analysis:

StateSizeBreakdown
Empty1 byte0x00 flag only
1 leaf, 0 ommers42 bytes1 + 8 + 32 + 1
~16 ommers (average)554 bytes1 + 8 + 32 + 1 + 16×32
32 ommers (maximum)1,066 bytes1 + 8 + 32 + 1 + 32×32

The frontier size is bounded by ~1.1KB regardless of how many millions of commitments have been appended. This makes the load→modify→save cycle very cheap (1 seek to read, 1 seek to write).

Element Representation

#![allow(unused)]
fn main() {
CommitmentTree(
    u64,                  // total_count: number of appended items
    u8,                   // chunk_power: dense tree height for BulkAppendTree buffer
    Option<ElementFlags>, // flags: optional metadata
)
}

The chunk_power parameter controls the BulkAppendTree buffer's dense tree height; chunk_power must be in the range 1..=16 (see §14.1 and §16).

Type identifiers:

IdentifierValue
Element discriminant11
TreeTypeCommitmentTree = 7
ElementType11
COMMITMENT_TREE_COST_SIZE12 bytes (8 total_count + 1 chunk_power + 1 discriminant + 2 overhead)

The Sinsemilla root is NOT stored in the Element. It flows as the Merk child hash through the insert_subtree mechanism. When the parent Merk computes its combined_value_hash, the Sinsemilla-derived root is included as the child hash:

combined_value_hash = blake3(value_hash || child_hash)
                                           ↑ sinsemilla/BulkAppendTree combined root

This means any change to the Sinsemilla frontier automatically propagates through the GroveDB Merk hierarchy to the state root.

Constructor methods:

MethodCreates
Element::empty_commitment_tree(chunk_power)Empty tree, count=0, no flags
Element::empty_commitment_tree_with_flags(chunk_power, flags)Empty tree with flags
Element::new_commitment_tree(total_count, chunk_power, flags)All fields explicit

Storage Architecture

The CommitmentTree stores all its data in a single data namespace at the subtree path. BulkAppendTree entries and the Sinsemilla frontier coexist in the same column using distinct key prefixes. No aux namespace is used.

┌──────────────────────────────────────────────────────────────────┐
│  Data Namespace (all CommitmentTree storage)                      │
│                                                                   │
│  BulkAppendTree storage keys (see §14.7):                         │
│    b"m" || pos (u64 BE)  → MMR node blobs                        │
│    b"b" || index (u64 BE)→ buffer entries (cmx || rho || ciphertext) │
│    b"e" || chunk (u64 BE)→ chunk blobs (compacted buffer)         │
│    b"M"                  → BulkAppendTree metadata                │
│                                                                   │
│  Sinsemilla frontier:                                             │
│    b"__ct_data__"        → serialized CommitmentFrontier (~1KB)   │
│                                                                   │
│  No Merk nodes — this is a non-Merk tree.                         │
│  Data authenticated via BulkAppendTree state_root (Blake3).       │
│  Sinsemilla root authenticates all cmx values via Pallas curve.   │
└──────────────────────────────────────────────────────────────────┘

The load→modify→save pattern: Every mutating operation loads the frontier from data storage, modifies it in memory, and writes it back. Since the frontier is at most ~1KB, this is an inexpensive pair of I/O operations (1 seek to read, 1 seek to write). Simultaneously, the BulkAppendTree is loaded, appended to, and saved.

Root hash propagation: When an item is inserted, two things change:

  1. The BulkAppendTree state changes (new entry in buffer or chunk compaction)
  2. The Sinsemilla root changes (new commitment in the frontier)

Both are captured in the updated CommitmentTree element. The parent Merk node hash becomes:

combined_hash = combine_hash(
    value_hash(element_bytes),    ← includes total_count + chunk_power
    child_hash(combined_root)     ← sinsemilla/BulkAppendTree combined root
)

Like MmrTree and BulkAppendTree, the type-specific root flows as the Merk child hash. All data authentication flows through this child hash binding.

Non-Merk data storage implications: Because the data namespace contains BulkAppendTree keys (not Merk nodes), operations that iterate storage as Merk elements — such as find_subtrees, is_empty_tree, and verify_merk_and_submerks — must special-case CommitmentTree (and other non-Merk tree types). The uses_non_merk_data_storage() helper on both Element and TreeType identifies these tree types. Delete operations clear the data namespace directly instead of iterating it, and verify_grovedb skips sub-merk recursion for these types.

GroveDB Operations

CommitmentTree provides four operations. The insert operation is generic over M: MemoSize (from the orchard crate), which controls ciphertext payload size validation. The default M = DashMemo gives a 216-byte payload (32 epk + 104 enc + 80 out).

#![allow(unused)]
fn main() {
// Insert a commitment (typed) — returns (sinsemilla_root, position)
// M controls ciphertext size validation
db.commitment_tree_insert::<_, _, M>(path, key, cmx, rho, ciphertext, tx, version)

// Insert a commitment (raw bytes) — validates payload.len() == ciphertext_payload_size::<DashMemo>()
db.commitment_tree_insert_raw(path, key, cmx, rho, payload_vec, tx, version)

// Get the current Orchard Anchor
db.commitment_tree_anchor(path, key, tx, version)

// Retrieve a value by global position
db.commitment_tree_get_value(path, key, position, tx, version)

// Get the current item count
db.commitment_tree_count(path, key, tx, version)
}

The typed commitment_tree_insert accepts a TransmittedNoteCiphertext<M> and serializes it internally. The raw commitment_tree_insert_raw (pub(crate)) accepts Vec<u8> and is used by batch preprocessing where payloads are already serialized.

commitment_tree_insert

The insert operation updates both the BulkAppendTree and the Sinsemilla frontier in a single atomic operation:

Step 1: Validate element at path/key is a CommitmentTree
        → extract total_count, chunk_power, flags

Step 2: Build ct_path = path ++ [key]

Step 3: Open data storage context at ct_path
        Load CommitmentTree (frontier + BulkAppendTree)
        Serialize ciphertext → validate payload size matches M
        Append cmx||rho||ciphertext to BulkAppendTree
        Append cmx to Sinsemilla frontier → get new sinsemilla_root
        Track Blake3 + Sinsemilla hash costs

Step 4: Save updated frontier to data storage

Step 5: Open parent Merk at path
        Write updated CommitmentTree element:
          new total_count, same chunk_power, same flags
        Child hash = combined_root (sinsemilla + bulk state)

Step 6: Propagate changes from parent upward through Merk hierarchy

Step 7: Commit storage batch and local transaction
        Return (sinsemilla_root, position)
graph TD
    A["commitment_tree_insert(path, key, cmx, rho, ciphertext)"] --> B["Validate: is CommitmentTree?"]
    B --> C["Open data storage, load CommitmentTree"]
    C --> D["Serialize & validate ciphertext size"]
    D --> E["BulkAppendTree.append(cmx||rho||payload)"]
    E --> F["frontier.append(cmx)"]
    F --> G["Save frontier to data storage"]
    G --> H["Update parent CommitmentTree element<br/>new sinsemilla_root + total_count"]
    H --> I["Propagate changes upward"]
    I --> J["Commit batch + transaction"]
    J --> K["Return (sinsemilla_root, position)"]

    style F fill:#fadbd8,stroke:#e74c3c,stroke-width:2px
    style E fill:#d5f5e3,stroke:#27ae60,stroke-width:2px
    style H fill:#d4e6f1,stroke:#2980b9,stroke-width:2px

Red = Sinsemilla operations. Green = BulkAppendTree operations. Blue = element update bridging both.

commitment_tree_anchor

The anchor operation is a read-only query:

Step 1: Validate element at path/key is a CommitmentTree
Step 2: Build ct_path = path ++ [key]
Step 3: Load frontier from data storage
Step 4: Return frontier.anchor() as orchard::tree::Anchor

The Anchor type is the Orchard-native representation of the Sinsemilla root, suitable for passing directly to orchard::builder::Builder when constructing spend authorization proofs.

commitment_tree_get_value

Retrieves a stored value (cmx || rho || payload) by its global position:

Step 1: Validate element at path/key is a CommitmentTree
        → extract total_count, chunk_power
Step 2: Build ct_path = path ++ [key]
Step 3: Open data storage context, wrap in CachedBulkStore
Step 4: Load BulkAppendTree, call get_value(position)
Step 5: Return Option<Vec<u8>>

This follows the same pattern as bulk_get_value (§14.9) — the BulkAppendTree transparently retrieves from the buffer or a compacted chunk blob depending on where the position falls.

commitment_tree_count

Returns the total number of items appended to the tree:

Step 1: Read element at path/key
Step 2: Verify it is a CommitmentTree
Step 3: Return total_count from element fields

This is a simple element field read — no storage access beyond the parent Merk.

Batch Operations

CommitmentTree supports batch inserts through the GroveOp::CommitmentTreeInsert variant:

#![allow(unused)]
fn main() {
GroveOp::CommitmentTreeInsert {
    cmx: [u8; 32],      // extracted note commitment
    rho: [u8; 32],      // nullifier of the spent note
    payload: Vec<u8>,    // serialized ciphertext (216 bytes for DashMemo)
}
}

Two constructors create this op:

#![allow(unused)]
fn main() {
// Raw constructor — caller serializes payload manually
QualifiedGroveDbOp::commitment_tree_insert_op(path, cmx, rho, payload_vec)

// Typed constructor — serializes TransmittedNoteCiphertext<M> internally
QualifiedGroveDbOp::commitment_tree_insert_op_typed::<M>(path, cmx, rho, &ciphertext)
}

Multiple inserts targeting the same tree are allowed in a single batch. Since execute_ops_on_path doesn't have access to data storage, all CommitmentTree ops must be preprocessed before apply_body.

The preprocessing pipeline (preprocess_commitment_tree_ops):

Input: [CTInsert{cmx1}, Insert{...}, CTInsert{cmx2}, CTInsert{cmx3}]
                                       ↑ same (path,key) as cmx1

Step 1: Group CommitmentTreeInsert ops by (path, key)
        group_1: [cmx1, cmx2, cmx3]

Step 2: For each group:
        a. Read existing element → verify CommitmentTree, extract chunk_power
        b. Open transactional storage context at ct_path
        c. Load CommitmentTree from data storage (frontier + BulkAppendTree)
        d. For each (cmx, rho, payload):
           - ct.append_raw(cmx, rho, payload) — validates size, appends to both
        e. Save updated frontier to data storage

Step 3: Replace all CTInsert ops with one ReplaceNonMerkTreeRoot per group
        carrying: hash=bulk_state_root (combined root),
                  meta=NonMerkTreeMeta::CommitmentTree {
                      total_count: new_count,
                      chunk_power,
                  }

Output: [ReplaceNonMerkTreeRoot{...}, Insert{...}]

The first CommitmentTreeInsert op in each group is replaced by the ReplaceNonMerkTreeRoot; subsequent ops for the same (path, key) are dropped. The standard batch machinery then handles element update and root hash propagation.

MemoSize Generic and Ciphertext Handling

The CommitmentTree<S, M> struct is generic over M: MemoSize (from the orchard crate). This controls the size of encrypted note ciphertexts stored alongside each commitment.

#![allow(unused)]
fn main() {
pub struct CommitmentTree<S, M: MemoSize = DashMemo> {
    frontier: CommitmentFrontier,
    pub bulk_tree: BulkAppendTree<S>,
    _memo: PhantomData<M>,
}
}

The default M = DashMemo means existing code that doesn't care about memo size (like verify_grovedb, commitment_tree_anchor, commitment_tree_count) works without specifying M.

Serialization helpers (public free functions):

FunctionDescription
ciphertext_payload_size::<M>()Expected payload size for a given MemoSize
serialize_ciphertext::<M>(ct)Serialize TransmittedNoteCiphertext<M> to bytes
deserialize_ciphertext::<M>(data)Deserialize bytes back to TransmittedNoteCiphertext<M>

Payload validation: The append_raw() method validates that payload.len() == ciphertext_payload_size::<M>() and returns CommitmentTreeError::InvalidPayloadSize on mismatch. The typed append() method serializes internally, so size is always correct by construction.

Stored Record Layout (280 bytes for DashMemo)

Each entry in the BulkAppendTree stores the complete encrypted note record. The full layout, accounting for every byte:

┌─────────────────────────────────────────────────────────────────────┐
│  Offset   Size   Field                                              │
├─────────────────────────────────────────────────────────────────────┤
│  0        32     cmx — extracted note commitment (Pallas base field)│
│  32       32     rho — nullifier of the spent note                  │
│  64       32     epk_bytes — ephemeral public key (Pallas point)    │
│  96       104    enc_ciphertext — encrypted note plaintext + MAC    │
│  200      80     out_ciphertext — encrypted outgoing data + MAC     │
├─────────────────────────────────────────────────────────────────────┤
│  Total:   280 bytes                                                 │
└─────────────────────────────────────────────────────────────────────┘

The first two fields (cmx and rho) are unencrypted protocol fields — they are public by design. The remaining three fields (epk_bytes, enc_ciphertext, out_ciphertext) form the TransmittedNoteCiphertext and are the encrypted payload.

Field-by-Field Breakdown

cmx (32 bytes) — The extracted note commitment, a Pallas base field element. This is the leaf value appended to the Sinsemilla frontier. It commits to all note fields (recipient, value, randomness) without revealing them. The cmx is what makes the note "findable" in the commitment tree.

rho (32 bytes) — The nullifier of the note being spent in this action. Nullifiers are already public on the blockchain (they must be to prevent double-spending). Storing rho alongside the commitment allows light clients performing trial decryption to verify esk = PRF(rseed, rho) and confirm epk' == epk without a separate nullifier lookup. This field sits between cmx and the ciphertext as an unencrypted protocol-level association.

epk_bytes (32 bytes) — The ephemeral public key, a serialized Pallas curve point. Derived deterministically from the note's rseed via:

rseed → esk = ToScalar(PRF^expand(rseed, [4] || rho))
esk   → epk = [esk] * g_d     (scalar multiplication on Pallas)
epk   → epk_bytes = Serialize(epk)

where g_d = DiversifyHash(d) is the diversified base point for the recipient's diversifier. The epk enables the recipient to compute the shared secret for decryption: shared_secret = [ivk] * epk. It is transmitted in the clear because it reveals nothing about sender or recipient without knowing either esk or ivk.

enc_ciphertext (104 bytes for DashMemo) — The encrypted note plaintext, produced by ChaCha20-Poly1305 AEAD encryption:

enc_ciphertext = ChaCha20-Poly1305.Encrypt(key, nonce=[0;12], aad=[], plaintext)
               = ciphertext (88 bytes) || MAC tag (16 bytes) = 104 bytes

The symmetric key is derived via ECDH: key = BLAKE2b-256("Zcash_OrchardKDF", shared_secret || epk_bytes).

When decrypted by the recipient (using ivk), the note plaintext (88 bytes for DashMemo) contains:

OffsetSizeFieldDescription
01versionAlways 0x02 (Orchard, post-ZIP-212)
111diversifier (d)Recipient's diversifier, derives the base point g_d
128value (v)64-bit little-endian note value in duffs
2032rseedRandom seed for deterministic derivation of rcm, psi, esk
5236memoApplication-layer memo data (DashMemo: 36 bytes)
Total88

The first 52 bytes (version + diversifier + value + rseed) are the compact note — light clients can trial-decrypt just this portion using the ChaCha20 stream cipher (without verifying the MAC) to check whether the note belongs to them. If it does, they decrypt the full 88 bytes and verify the MAC.

out_ciphertext (80 bytes) — The encrypted outgoing data, allowing the sender to recover the note after the fact. Encrypted with the Outgoing Cipher Key:

ock = BLAKE2b-256("Zcash_Orchardock", ovk || cv_net || cmx || epk)
out_ciphertext = ChaCha20-Poly1305.Encrypt(ock, nonce=[0;12], aad=[], plaintext)
               = ciphertext (64 bytes) || MAC tag (16 bytes) = 80 bytes

When decrypted by the sender (using ovk), the outgoing plaintext (64 bytes) contains:

OffsetSizeFieldDescription
032pk_dDiversified transmission key (recipient's public key)
3232eskEphemeral secret key (Pallas scalar)
Total64

With pk_d and esk, the sender can reconstruct the shared secret, decrypt enc_ciphertext, and recover the full note. If the sender sets ovk = null, the outgoing plaintext is filled with random bytes before encryption, making recovery impossible even for the sender (non-recoverable output).

Encryption Scheme: ChaCha20-Poly1305

Both enc_ciphertext and out_ciphertext use ChaCha20-Poly1305 AEAD (RFC 8439):

ParameterValue
Key size256 bits (32 bytes)
Nonce[0u8; 12] (safe because each key is used exactly once)
AADEmpty
MAC tag16 bytes (Poly1305)

The zero nonce is safe because the symmetric key is derived from a fresh Diffie-Hellman exchange per note — each key encrypts exactly one message.

DashMemo vs ZcashMemo Size Comparison

ComponentDashMemoZcashMemoNotes
Memo field36 bytes512 bytesApplication data
Note plaintext88 bytes564 bytes52 fixed + memo
enc_ciphertext104 bytes580 bytesplaintext + 16 MAC
Ciphertext payload (epk+enc+out)216 bytes692 bytesTransmitted per note
Full stored record (cmx+rho+payload)280 bytes756 bytesBulkAppendTree entry

DashMemo's smaller memo (36 vs 512 bytes) reduces each stored record by 476 bytes — significant when storing millions of notes.

Trial Decryption Flow (Light Client)

A light client scanning for its own notes performs this sequence for each stored record:

1. Read record: cmx (32) || rho (32) || epk (32) || enc_ciphertext (104) || out_ciphertext (80)

2. Compute shared_secret = [ivk] * epk     (ECDH with incoming viewing key)

3. Derive key = BLAKE2b-256("Zcash_OrchardKDF", shared_secret || epk)

4. Trial-decrypt compact note (first 52 bytes of enc_ciphertext):
   → version (1) || diversifier (11) || value (8) || rseed (32)

5. Reconstruct esk = PRF(rseed, rho)    ← rho is needed here!
   Verify: [esk] * g_d == epk           ← confirms this is our note

6. If match: decrypt full enc_ciphertext (88 bytes + 16 MAC):
   → compact_note (52) || memo (36)
   Verify MAC tag for authenticity

7. Reconstruct full Note from (diversifier, value, rseed, rho)
   This note can later be spent by proving knowledge of it in ZK

Step 5 is why rho must be stored alongside the ciphertext — without it, the light client cannot verify the ephemeral key binding during trial decryption.

Client-Side Witness Generation

The grovedb-commitment-tree crate provides a client-side tree for wallets and test harnesses that need to generate Merkle witness paths for spending notes. Enable the client feature to use it:

grovedb-commitment-tree = { version = "4", features = ["client"] }
#![allow(unused)]
fn main() {
pub struct ClientMemoryCommitmentTree {
    inner: ShardTree<MemoryShardStore<MerkleHashOrchard, u32>, 32, 4>,
}
}

The ClientMemoryCommitmentTree wraps ShardTree — a full commitment tree (not just a frontier) that keeps complete history in memory. This allows generating authentication paths for any marked leaf, which the frontier alone cannot do.

API:

MethodDescription
new(max_checkpoints)Create empty tree with checkpoint retention limit
append(cmx, retention)Append a commitment with retention policy
checkpoint(id)Create a checkpoint at current state
max_leaf_position()Position of most recently appended leaf
witness(position, depth)Generate MerklePath for spending a note
anchor()Current root as orchard::tree::Anchor

Retention policies control which leaves can be witnessed later:

RetentionMeaning
Retention::EphemeralLeaf cannot be witnessed (other people's notes)
Retention::MarkedLeaf can be witnessed (your own notes)
Retention::Checkpoint { id, marking }Create a checkpoint, optionally mark

Server vs Client comparison:

CommitmentFrontier (server)ClientMemoryCommitmentTree (client)ClientPersistentCommitmentTree (sqlite)
Storage~1KB frontier in data storageFull tree in memoryFull tree in SQLite
Can witnessNoYes (marked leaves only)Yes (marked leaves only)
Can compute anchorYesYesYes
Anchor matchesSame sequence → same anchorSame sequence → same anchorSame sequence → same anchor
Persists across restartsYes (GroveDB data storage)No (lost on drop)Yes (SQLite database)
Use caseGroveDB server-side anchor trackingTesting, ephemeral walletsProduction wallets
Feature flagserverclientsqlite

All three produce identical anchors for the same sequence of appends. This is verified by the test_frontier_and_client_same_root test.

Persistent Client — SQLite-Backed Witness Generation

The in-memory ClientMemoryCommitmentTree loses all state on drop. For production wallets that must survive restarts without re-scanning the entire blockchain, the crate provides ClientPersistentCommitmentTree backed by SQLite. Enable the sqlite feature:

grovedb-commitment-tree = { version = "4", features = ["sqlite"] }
#![allow(unused)]
fn main() {
pub struct ClientPersistentCommitmentTree {
    inner: ShardTree<SqliteShardStore, 32, 4>,
}
}

Three constructor modes:

ConstructorDescription
open(conn, max_checkpoints)Takes ownership of an existing rusqlite::Connection
open_on_shared_connection(arc, max_checkpoints)Shares an Arc<Mutex<Connection>> with other components
open_path(path, max_checkpoints)Convenience — opens/creates a SQLite DB at the given file path

The bring-your-own-connection constructors (open, open_on_shared_connection) allow the wallet to use its existing database for commitment tree storage. The SqliteShardStore creates its tables with a commitment_tree_ prefix, so it coexists safely alongside other application tables.

API is identical to ClientMemoryCommitmentTree:

MethodDescription
append(cmx, retention)Append a commitment with retention policy
checkpoint(id)Create a checkpoint at current state
max_leaf_position()Position of most recently appended leaf
witness(position, depth)Generate MerklePath for spending a note
anchor()Current root as orchard::tree::Anchor

SQLite schema (4 tables, created automatically):

commitment_tree_shards                -- Shard data (serialized prunable trees)
commitment_tree_cap                   -- Tree cap (single-row, top of shard tree)
commitment_tree_checkpoints           -- Checkpoint metadata (position or empty)
commitment_tree_checkpoint_marks_removed  -- Marks removed per checkpoint

Persistence example:

#![allow(unused)]
fn main() {
use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Retention, Position};

// First session: append notes and close
let mut tree = ClientPersistentCommitmentTree::open_path("wallet.db", 100)?;
tree.append(cmx_0, Retention::Marked)?;
tree.append(cmx_1, Retention::Ephemeral)?;
let anchor_before = tree.anchor()?;
drop(tree);

// Second session: reopen, state is preserved
let tree = ClientPersistentCommitmentTree::open_path("wallet.db", 100)?;
let anchor_after = tree.anchor()?;
assert_eq!(anchor_before, anchor_after);  // same anchor, no re-scan needed
}

Shared connection example (for wallets with an existing SQLite database):

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use grovedb_commitment_tree::rusqlite::Connection;

let conn = Arc::new(Mutex::new(Connection::open("wallet.db")?));
// conn is also used by other wallet components...
let mut tree = ClientPersistentCommitmentTree::open_on_shared_connection(
    conn.clone(), 100
)?;
}

The grovedb-commitment-tree crate re-exports rusqlite under the sqlite feature flag, so downstream consumers do not need to add rusqlite as a separate dependency.

SqliteShardStore internals:

The SqliteShardStore implements all 18 methods of the ShardStore trait. Shard trees are serialized using a compact binary format:

Nil:    [0x00]                                     — 1 byte
Leaf:   [0x01][hash: 32][flags: 1]                 — 34 bytes
Parent: [0x02][has_ann: 1][ann?: 32][left][right]  — recursive

LocatedPrunableTree adds an address prefix: [level: 1][index: 8][tree_bytes].

The ConnectionHolder enum abstracts over owned vs shared connections:

#![allow(unused)]
fn main() {
enum ConnectionHolder {
    Owned(Connection),                    // exclusive access
    Shared(Arc<Mutex<Connection>>),       // shared with other components
}
}

All database operations acquire the connection through a with_conn helper that transparently handles both modes, locking the mutex only when shared.

Proof Integration

CommitmentTree supports two proof paths:

1. Sinsemilla anchor proof (ZK path):

GroveDB root hash
  ↓ Merk proof (V0, standard)
Parent Merk node
  ↓ value_hash includes CommitmentTree element bytes
CommitmentTree element bytes
  ↓ contains sinsemilla_root field
Sinsemilla root (Orchard Anchor)
  ↓ ZK proof (Halo 2 circuit, off-chain)
Note commitment at position P
  1. The parent Merk proof demonstrates that the CommitmentTree element exists at the claimed path/key, with specific bytes.
  2. Those bytes include the sinsemilla_root field.
  3. The client (wallet) independently constructs a Merkle witness in the Sinsemilla tree using ClientMemoryCommitmentTree::witness() (testing) or ClientPersistentCommitmentTree::witness() (production, SQLite-backed).
  4. The ZK circuit verifies the witness against the anchor (sinsemilla_root).

2. Item retrieval proof (V1 path):

Individual items (cmx || rho || payload) can be queried by position and proved using V1 proofs (§9.6), the same mechanism used by standalone BulkAppendTree. The V1 proof includes the BulkAppendTree authentication path for the requested position, chained to the parent Merk proof for the CommitmentTree element.

Cost Tracking

CommitmentTree introduces a dedicated cost field for Sinsemilla operations:

#![allow(unused)]
fn main() {
pub struct OperationCost {
    pub seek_count: u32,
    pub storage_cost: StorageCost,
    pub storage_loaded_bytes: u64,
    pub hash_node_calls: u32,
    pub sinsemilla_hash_calls: u32,   // ← new field for CommitmentTree
}
}

The sinsemilla_hash_calls field is separate from hash_node_calls because Sinsemilla hashes are dramatically more expensive than Blake3 in both CPU time and ZK circuit cost.

Per-append cost breakdown:

ComponentAverage caseWorst case
Sinsemilla hashes33 (32 root + 1 ommer avg)64 (32 root + 32 ommers)
Frontier I/O seeks2 (get + put)2
Frontier bytes loaded554 (~16 ommers)1,066 (32 ommers)
Frontier bytes written5541,066
BulkAppendTree hashes~5 Blake3 (amortized, see §14.15)O(chunk_size) on compaction
BulkAppendTree I/O2-3 seeks (metadata + buffer)+2 on chunk compaction

Cost estimation constants (from average_case_costs.rs and worst_case_costs.rs):

#![allow(unused)]
fn main() {
// Average case
const AVG_FRONTIER_SIZE: u32 = 554;    // ~16 ommers
const AVG_SINSEMILLA_HASHES: u32 = 33; // 32 root levels + 1 avg ommer

// Worst case
const MAX_FRONTIER_SIZE: u32 = 1066;   // 32 ommers (max depth)
const MAX_SINSEMILLA_HASHES: u32 = 64; // 32 root levels + 32 ommers
}

The BulkAppendTree component cost is tracked alongside the Sinsemilla cost, combining both Blake3 hashes (from BulkAppendTree buffer/chunk operations) and Sinsemilla hashes (from frontier append) into a single OperationCost.

The Orchard Key Hierarchy and Re-exports

The grovedb-commitment-tree crate re-exports the full Orchard API needed to construct and verify shielded transactions. This allows Platform code to import everything from a single crate.

Key management types:

SpendingKey
  ├── SpendAuthorizingKey → SpendValidatingKey
  └── FullViewingKey
        ├── IncomingViewingKey (decrypt received notes)
        ├── OutgoingViewingKey (decrypt sent notes)
        └── Address (= PaymentAddress, derive recipient addresses)

Note types:

TypePurpose
NoteFull note with value, recipient, randomness
ExtractedNoteCommitmentThe cmx extracted from a note (32 bytes)
NullifierUnique tag that marks a note as spent
RhoNullifier derivation input (links spend to prior note)
NoteValue64-bit note value
ValueCommitmentPedersen commitment to a note value

Proof and bundle types:

TypePurpose
ProvingKeyHalo 2 proving key for Orchard circuits
VerifyingKeyHalo 2 verifying key for Orchard circuits
BatchValidatorBatch verification of multiple Orchard bundles
Bundle<T, V>Collection of Actions forming a shielded transfer
ActionSingle spend/output pair within a bundle
AuthorizedBundle authorization state (signatures + ZK proof)
FlagsBundle flags (spends enabled, outputs enabled)
ProofThe Halo 2 proof within an authorized bundle

Builder types:

TypePurpose
BuilderConstructs an Orchard bundle from spends and outputs
BundleTypeConfigures padding strategy for the bundle

Tree types:

TypePurpose
AnchorSinsemilla root as an Orchard-native type
MerkleHashOrchardSinsemilla hash node in the commitment tree
MerklePath32-level authentication path for witness generation

Implementation Files

FilePurpose
grovedb-commitment-tree/src/lib.rsCommitmentFrontier struct, serialization, EMPTY_SINSEMILLA_ROOT, re-exports
grovedb-commitment-tree/src/commitment_tree/mod.rsCommitmentTree<S, M> struct, typed/raw append, ciphertext ser/de helpers
grovedb-commitment-tree/src/commitment_frontier/mod.rsCommitmentFrontier (Sinsemilla frontier wrapping Frontier)
grovedb-commitment-tree/src/error.rsCommitmentTreeError (including InvalidPayloadSize)
grovedb-commitment-tree/src/client/mod.rsClientMemoryCommitmentTree, in-memory witness generation
grovedb-commitment-tree/src/client/sqlite_store.rsSqliteShardStore, ShardStore impl over SQLite, tree serialization
grovedb-commitment-tree/src/client/client_persistent_commitment_tree.rsClientPersistentCommitmentTree, SQLite-backed witness generation
grovedb-commitment-tree/Cargo.tomlFeature flags: server, client, sqlite
grovedb-element/src/element/mod.rsElement::CommitmentTree variant (3 fields: u64, u8, Option<ElementFlags>)
grovedb-element/src/element/constructor.rsempty_commitment_tree(chunk_power), new_commitment_tree_with_all()
grovedb-element/src/element/helpers.rsuses_non_merk_data_storage() helper
merk/src/tree_type/costs.rsCOMMITMENT_TREE_COST_SIZE = 12
merk/src/tree_type/mod.rsTreeType::CommitmentTree = 7, uses_non_merk_data_storage()
grovedb/src/operations/commitment_tree.rsGroveDB operations: typed insert, raw insert, anchor, get_value, count, batch preprocessing
grovedb/src/operations/delete/mod.rsNon-Merk tree type delete handling
grovedb/src/batch/mod.rsGroveOp::CommitmentTreeInsert, commitment_tree_insert_op_typed constructor
grovedb/src/batch/estimated_costs/average_case_costs.rsAverage case cost model
grovedb/src/batch/estimated_costs/worst_case_costs.rsWorst case cost model
grovedb/src/tests/commitment_tree_tests.rs32 integration tests

Comparison with Other Tree Types

CommitmentTreeMmrTreeBulkAppendTreeDenseTree
Element discriminant11121314
TreeType78910
Has child MerkNoNoNoNo
Data namespaceBulkAppendTree entries + Sinsemilla frontierMMR nodesBuffer + chunks + MMRValues by position
Hash functionSinsemilla + Blake3Blake3Blake3Blake3
Proof typeV1 (Bulk) + ZK (Sinsemilla)V1 (MMR proof)V1 (Bulk proof)V1 (DenseTree proof)
Hashes per append~33 Sinsemilla + ~5 Blake3~2 Blake3~5 Blake3 (amortized)O(n) Blake3
Cost size12 bytes11 bytes12 bytes6 bytes
CapacityUnlimitedUnlimitedUnlimitedFixed (2^h - 1)
ZK-friendlyYes (Halo 2)NoNoNo
Chunk compactionYes (configurable chunk_power)NoYesNo
Use caseShielded note commitmentsEvent/transaction logsHigh-throughput bulk logsSmall bounded structures

Choose CommitmentTree when you need ZK-provable anchors for shielded protocols with efficient chunk-compacted storage. Choose MmrTree when you need a simple append-only log with individual leaf proofs. Choose BulkAppendTree when you need high-throughput range queries with chunk-based snapshots. Choose DenseAppendOnlyFixedSizeTree when you need a compact, fixed-capacity structure where every position stores a value and the root hash is always recomputed on the fly.