Help us improve
Share bugs, ideas, or general feedback.
From core-concepts
This skill should be used when understanding privacy-preserving design patterns, including commitment schemes, nullifier patterns, Merkle tree membership proofs, anonymous authentication, commit-reveal protocols, selective disclosure, domain separation, and privacy boundaries in on-chain data.
npx claudepluginhub devrelaicom/midnight-expert --plugin core-conceptsHow this skill is triggered — by the user, by Claude, or both
Slash command
/core-concepts:privacy-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Privacy-preserving design patterns for Compact smart contracts. Covers commitment schemes, nullifiers, Merkle tree membership proofs, round-based unlinkability, selective disclosure, and threat analysis. For basic visibility rules per ledger operation, see `compact-ledger`. For standard library function signatures, see `compact-standard-library`. For shielded token privacy, see `compact-tokens`.
This skill should be used when the user asks about Midnight's privacy model, the disclose() function and disclosure rules, how to fix disclosure compiler errors, privacy-by-default design, witness protection program, commitment schemes (persistentCommit, transientCommit), nullifier patterns for double-spend prevention, MerkleTree membership proofs for anonymous authentication, unlinkable actions via round-based keys, selective disclosure, commit-reveal schemes, shielded vs transparent state design, what is visible on-chain, safe stdlib routines (transientCommit hiding witness data), or debugging "potential witness-value disclosure must be declared" errors.
This skill should be used when the user asks about zero-knowledge proofs, ZK SNARKs, witness data, prover/verifier roles, constraint systems, proof generation, proof verification, privacy boundaries, or how Midnight uses ZK cryptography for transaction privacy and data protection.
Guides building zero-knowledge proof verifiers and privacy patterns on Stellar/Soroban, covering Groth16, BLS12-381, BN254, Poseidon, Noir/RISC Zero integration, and more.
Share bugs, ideas, or general feedback.
Privacy-preserving design patterns for Compact smart contracts. Covers commitment schemes, nullifiers, Merkle tree membership proofs, round-based unlinkability, selective disclosure, and threat analysis. For basic visibility rules per ledger operation, see compact-ledger. For standard library function signatures, see compact-standard-library. For shielded token privacy, see compact-tokens.
When not to use this skill: For basic ledger visibility rules per operation, see compact-ledger. For token balance operations and shielded coin mechanics, see compact-tokens. For standard library function signatures and type details, see compact-standard-library.
| What to Protect | Approach | Key Primitives |
|---|---|---|
| Hide a value on-chain | Commitment | persistentCommit<T> / transientCommit<T> |
| Prove membership anonymously | MerkleTree + ZK path | HistoricMerkleTree + merkleTreePathRoot<N, T> |
| Prevent double-actions | Nullifier | persistentHash<T> with domain separation + Set<Bytes<32>> |
| Hide who is acting | Unlinkable auth | Counter + rotated persistentHash |
| Multi-step hidden value | Commit-reveal | Commit phase + reveal phase |
| Private token balances | Shielded tokens | Zswap infrastructure (see compact-tokens) |
| Share specific data only | Selective disclosure | disclose() on boolean result, not the value |
A commitment hides a value behind cryptographic randomness while binding the committer to that value. Compact provides hash-based commitments (not algebraic Pedersen commitments -- those are used internally by Zswap for balance proofs, a separate mechanism).
| Function | Signature | Clears Witness Taint | Use Case |
|---|---|---|---|
persistentCommit<T> | (value: T, rand: Bytes<32>): Bytes<32> | Yes | Hide a value you will reveal later |
persistentHash<T> | (value: T): Bytes<32> | No | Derive a binding fingerprint (public keys, nullifiers) |
transientCommit<T> | (value: T, rand: Field): Field | Yes | In-circuit intermediates only; algorithm may change between compiler versions |
transientHash<T> | (value: T): Field | No | In-circuit consistency checks only |
Persistent vs transient: Persistent functions use SHA-256 and produce stable outputs across compiler upgrades. Transient functions are circuit-optimized but their algorithm may change between compiler versions, so outputs must not be stored in ledger state.
When to use commit vs hash: Use persistentCommit when you need to hide a value on-chain and later prove you committed to it (commit-reveal schemes, sealed bids). Use persistentHash when binding is sufficient and the hash itself is not secret (public key derivation, nullifiers, domain-separated identifiers). Note that persistentHash<T> accepts any serializable type T, not just Bytes<32>.
Column note: "Clears Witness Taint" means the compiler no longer requires disclose() for values that flowed through the function's input. The commitment cryptographically hides the input, so the compiler considers it safe. Hash functions do not provide this guarantee because hash outputs could theoretically be brute-forced.
How it works in practice: A circuit accepts a value to commit and calls a witness function to obtain fresh randomness (a 32-byte salt). It then calls persistentCommit over a domain-separated vector containing the value and a purpose prefix (created with pad(32, "myapp:commit:")). The resulting commitment is stored on-chain. Because persistentCommit clears witness taint, no disclose() is needed for the ledger write. The opening (commitment, salt, and value) is stored off-chain via a witness function so the committer can reveal later. Randomness must never be reused across commitments.
See references/commitment-schemes.md for detailed commitment properties, reveal patterns, and salt management.
A nullifier prevents double-actions without revealing which action is being prevented. It is a deterministic derivation from a secret: the same secret always produces the same nullifier, so a Set check catches reuse, but the nullifier itself reveals nothing about the underlying identity.
Nullifiers are derived by calling persistentHash over a domain-separated vector containing a unique purpose prefix (e.g., pad(32, "contract:purpose:")), the secret, and any additional context-specific inputs.
Domain separation is critical. Nullifiers for different purposes MUST use different domain prefixes. Without domain separation, an observer who sees a nullifier from one contract can check whether the same secret was used in another contract.
If you derive both a commitment and a nullifier from the same secret, use different domain separators so an observer cannot match commitments to nullifiers. For example, use pad(32, "myapp:commit:") for the commitment and pad(32, "myapp:nul:") for the nullifier. Using the same domain prefix for both produces identical outputs, enabling a linking attack.
To allow one action per round (e.g., voting in multiple rounds), incorporate a round counter into the nullifier derivation. A deriveNullifier circuit takes the round number (as Uint<64>) and the secret key, casts the round through Field to Bytes<32> (two-step cast required: Uint<64> cannot cast directly to Bytes<32>), then calls persistentHash over a three-element vector containing the round-specific domain prefix, the round bytes, and the secret key. Each round produces a distinct nullifier from the same secret, allowing one action per round while still preventing double-actions within a round.
Nullifiers are stored in a Set<Bytes<32>> ledger variable. This is public on-chain by design: the nullifier is already a derived value and reveals nothing about the underlying secret. To check and insert: derive the nullifier, then call Set.member(disclose(nul)) to check for reuse (the disclose() is required because the nullifier is witness-derived and Set arguments must be public), and Set.insert(disclose(nul)) to record it.
The Midnight zerocash implementation demonstrates the canonical commitment and nullifier separation. Its derive_nullifier circuit calls persistentHash over a four-element vector containing a domain prefix ("lares:zerocash:commit"), the coin nonce, the coin opening, and the secret key. The result is disclosed (nullifiers are public by design) and wrapped in a nullifier struct. Note: the domain string "lares:zerocash:commit" is a historical naming artifact from the reference implementation where the same function was reused for both commitment and nullifier derivation -- the domain string was not updated when the purposes diverged.
MerkleTree and HistoricMerkleTree enable anonymous set membership proofs. The observer sees that someone proved membership, but not which member.
Use HistoricMerkleTree<N, T> instead of MerkleTree<N, T> when members are added over time. HistoricMerkleTree.checkRoot() accepts proofs against any prior version of the tree, so a proof generated before new members were added remains valid. With plain MerkleTree, each insertion changes the root and invalidates all existing proofs.
Admin inserts commitments on-chain. tree.insert(commitment) adds a leaf. The leaf value is hidden on-chain (the special privacy property of MerkleTree and HistoricMerkleTree inserts).
User obtains a MerkleTreePath off-chain. The witness function queries the local copy of the tree state. TypeScript provides findPathForLeaf(leaf) (O(n) scan) or pathForLeaf(index, leaf) (O(log n) by index).
Circuit computes the root. merkleTreePathRoot<N, T>(path) recomputes the Merkle root from the path. The MerkleTreePath<N, T> struct has fields leaf: T and path: Vector<N, MerkleTreePathEntry>, where each MerkleTreePathEntry has sibling: MerkleTreeDigest and goes_left: Boolean. Pass the whole struct -- there is no .value field.
Circuit verifies the root on-chain. tree.checkRoot(disclose(digest)) confirms the computed root matches a current (or historic) root. The disclose() is required because the digest is derived from witness data (the path). There is no historicMember method -- use checkRoot only.
The contract declares an HistoricMerkleTree<16, Bytes<32>> for member registration and a Set<Bytes<32>> for spent nullifiers. A witness provides the user's secret key; another witness returns the MerkleTreePath for the user's public key.
Admin registration: An addMember circuit inserts a member's public key commitment into the tree. The leaf value is hidden on-chain (the special privacy property of MerkleTree inserts), though disclose() is still required on the argument.
Anonymous action (four steps):
persistentHash with a "myapp:pk:" domain prefix, then calls a witness to get the MerkleTreePath for that public key.merkleTreePathRoot<16, Bytes<32>>(memberPath) passing the whole MerkleTreePath struct (there is no .value field).members.checkRoot(disclose(digest)) to confirm the computed root matches a current or historic root. The disclose() is required because the digest is derived from witness data.persistentHash with a different domain prefix ("myapp:act-nul:"), check it is not in the spent set, and insert it. Both Set.member() and Set.insert() require disclose() on the witness-derived nullifier.Capacity planning: HistoricMerkleTree<N, T> holds at most 2^N leaves. Depth 16 supports 65,536 members; depth 20 supports about 1 million. Depth also determines proof size (N sibling hashes), so balance capacity against circuit cost.
Leaf guessing caveat: If the set of possible leaf values is small (e.g., only 10 known public keys), an observer can verify guesses against the tree. Mitigate by using commitments (hashed with randomness) as leaves instead of raw public keys.
See references/merkle-tree-usage.md for detailed Merkle tree patterns and TypeScript integration.
This pattern breaks the link between successive transactions from the same user. Instead of storing a fixed public key on-chain, each transaction derives a round-specific key and rotates the stored authority.
A publicKey circuit derives a round-specific key by calling persistentHash over a three-element vector: a "myapp:pk:" domain prefix, the round number (cast through Field to Bytes<32> -- the two-step cast is required), and the secret key. The contract stores the current authority hash and a Counter for the round number.
Each transaction:
Counter.read() returns Uint<64>)disclose() needed because the key is witness-derived)authority (disclose() needed for the ledger write)Observer perspective: Each transaction shows a different authority hash. Without knowing the secret key, the observer cannot determine that the same user authorized all transactions.
Limitation: The first transaction that initializes the authority is a unique event (the constructor sets it). An observer can identify the deployment transaction. Subsequent transactions are unlinkable to each other but not to the deployment.
Selective disclosure proves a property about private data without revealing the data itself. The key technique: disclose() the boolean result of a comparison, not the underlying value.
Prove a witness-held value exceeds a threshold without revealing the value. Note: comparison operators (>=, <=, >, <) only work on Uint<N>, not Field.
The circuit obtains the credential value (as Uint<64>, required for comparisons) and its salt from witnesses. It recomputes the commitment via persistentCommit<Uint<64>>(value, salt) and asserts it matches the on-chain commitment (no disclose() needed on the result because persistentCommit clears taint). Then it discloses only the boolean result of the comparison: disclose(value >= threshold). The value itself never leaves the circuit.
The same pattern extends to range checks: after verifying the credential commitment, disclose a combined boolean expression like disclose(value >= minimum && value <= maximum). The range boundaries are public circuit parameters; the value remains private.
When working with structured data, disclose only specific fields. For example, a witness might return a profile tuple containing a name (Bytes<32>), age (Uint<64>), and income (Uint<64>). A circuit can destructure this tuple and disclose only the boolean result of an age comparison -- disclose(age >= minAge) -- while the name and income fields never leave the circuit. The key principle: disclose the boolean result of a check, not the underlying data.
MerkleTree.insert() hides its leaf argument.disclose()d values (by definition, intentionally public)MerkleTree.insert() and HistoricMerkleTree.insert() (the only ledger operations that hide their data argument)| Attack | Mitigation |
|---|---|
| Small anonymity set | Add dummy members to increase set size |
| Timing correlation | Introduce random delays; batch transactions |
| Amount fingerprinting | Standardize amounts; split into uniform denominations |
| Leaf guessing | Use committed values (with randomness) as MerkleTree leaves |
| Nullifier timing | Decouple registration order from action order |
| Circuit selection | Use a single circuit with internal branching where feasible |
| Wrong | Correct | Why |
|---|---|---|
Set for private membership | MerkleTree + ZK path proof | Set reveals which element is tested (note: only an issue when element identity must be hidden) |
| Missing domain separator on nullifiers | Always prefix with unique pad(32, "contract:purpose:") | Prevents cross-contract correlation |
persistentHash to "hide" witness data | persistentCommit with randomness | Hash does not clear witness taint; commit does |
| Same derivation for commitment and nullifier | Different domain separators | Prevents linking attack |
| Disclosing at witness call site | Disclose at the disclosure point | Over-discloses; all downstream uses lose privacy |
| Reusing salts across commitments | Unique randomness per commitment | Same value + same salt = same output |
round as Bytes<32> cast for Uint<64> | (round as Field) as Bytes<32> two-step | Direct Uint<64> to Bytes<32> cast is invalid |
>= / <= on Field type | Use Uint<64> for comparisons | Comparison operators only work on Uint<N> |
merkleTreePathRoot(path.value) | merkleTreePathRoot(path) passing whole struct | MerkleTreePath has no .value field |
| Topic | File |
|---|---|
Commitment properties, hiding/binding, persistentCommit vs transientCommit, salt management | references/commitment-schemes.md |
MerkleTree/HistoricMerkleTree, MerkleTreePath struct, checkRoot pattern, TypeScript integration | references/merkle-tree-usage.md |