Zero-Knowledge Non-Membership Proofs on Midnight

#midnight#zero-knowledge#compact#smart-contracts#privacy#non-membership#merkle-trees#indexed-merkle-trees#nullifiers Compact v0.31.0

The views, opinions, and creative abuses of language in this post are my own and do not represent those of my employer (Shielded Technologies) or the Midnight Network.

How to prove you’re NOT a member

I don’t want to belong to any club that will accept me as a member. — Groucho Marx

A serious and good philosophical work could be written consisting entirely of jokes. — Ludwig Wittgenstein


Groucho Marx didn’t want to belong to any club that would have him. What he never explained was how he’d actually prove he wasn’t a member. A zero-knowledge proof of non-membership does exactly that; it lets someone demonstrate that an element is absent from a set, without revealing the element or the set’s contents. How is this applied? Proving you’re not on a sanctions list, that a credential hasn’t been revoked, that you haven’t already voted…

Proving non-membership is harder than proving membership. Membership proofs have well-worn primitives, such as Merkle trees and accumulators, where you show a path from your element in the tree to a known root. Non-membership requires proving a negative: that no path exists, or that the position where your element would be is empty. Furthermore, it requires proving that a given value/element is not present in a potentially infinite, or in the case of Midnight, hidden set.

Three things make this genuinely hard.

1. There’s no natural witness for absence.

I have not failed. I’ve just found 10,000 ways that won’t work. — Thomas Edison
Absence of evidence is not evidence of absence. — Carl Sagan

A membership proof points at one path; a non-membership proof has to rule out every other path. The naive translation — assert x ≠ s₁ and x ≠ s₂ and … and x ≠ sₙ inside the circuit — works but scales linearly with the set, and the circuit gets unworkable fast.

2. The set itself is often the secret.

The first rule of Fight Club is: you do not talk about Fight Club. — Chuck Palahniuk

Proving a credential isn’t on a confidential blacklist means the verifier shouldn’t learn the list either, which rules out the trivial “just publish the set” workaround.

3. The constraints add up.

There ain’t no such thing as a free lunch. — Robert Heinlein, The Moon Is a Harsh Mistress

Each comparison or hash inside the circuit costs gates; each gate costs proving time. A million-entry set checked element-by-element is minutes-to-hours of proving, not seconds.

The four patterns below sit at different points along these three axes.

This post walks through four patterns for proving non-membership on Midnight, from native primitives that need nothing but the chain itself to layouts that lean on off-chain infrastructure. All contracts compile against Compact v0.31.0 and are linked inline. Some of these are very basic. Some are not for the faint-hearted.

Pattern Overview

TechniqueUsable set sizeOff-chain infraNative supportDifficulty
1 - Nullifier patternUnlimitedNoneFullBeginner
2 - Attribute blocklist~32 entriesNoneFullIntermediate
3 - Sparse Merkle treeMillions (with collision risk!)Tree serviceManualAdvanced
4 - Indexed Merkle treeMillionsSorted-leaf serviceManualAdvanced

Try the contracts

All four patterns are deployed on Midnight preprod and exposed through a browser DApp at non-membership.pages.dev. Patterns 3 and 4 also ship with an interactive sparse-Merkle / indexed-Merkle visualizer that lets you watch insertions, look at predecessor proofs, and step through a non-membership check leaf by leaf.

You’ll need three things to actually transact:

  • The Lace wallet browser extension, or any other wallet implementing the Midnight DApp connector (such as Moth), with the network switched to preprod.
  • A handful of tmNIGHT from the Nethermind preprod faucet — paste your wallet’s unshielded address, wait a minute or two for the tokens to arrive, then give DUST a few minutes to accrue before submitting transactions.
  • A local proof server reachable at localhost:6300, since ZK proofs are generated client-side; the DApp shows a green/red dot in the header to tell you whether it’s reachable.

Source for each of the patterns is linked inline below.

Pattern 1: Nullifier-Based Prevention

I didn’t do it, nobody saw me do it, you can’t prove anything. — Bart Simpson
No two substances are entirely alike. — Gottfried Wilhelm Leibniz

The Midnight-native approach. Instead of proving “I am NOT in set S,” you prove “my deterministic nullifier has NOT been published on-chain.” The blockchain itself enforces uniqueness. The pattern is well-worn and straightforward.

How it works

  1. User holds a secret key (never leaves the ZK proof)
  2. User derives a deterministic nullifier: persistentHash(domain, secret, context)
  3. Circuit checks the nullifier is NOT in the on-chain Set<Bytes<32>>
  4. Circuit inserts the nullifier, permanently marking it “used”

The nullifier is a one-way hash of the secret value, and that one-wayness is exactly what makes this pattern work. Without it, an observer could walk the public nullifier backwards to the secret key. The preimage resistance of persistentHash makes that inversion computationally infeasible, which is exactly what we want. The hash function does not erase the in-circuit taint label (the API reference notes it’s “insufficient to protect its input from disclosure”), which is why every ledger write of a witness-derived hash still calls disclose(). The no natural witness for absence problem reappears here in Compact-specific form: the witness value (the secret) is taint-labeled, the derived nullifier is also taint-labeled, and the only way to get either onto the chain is an explicit disclose() that you have to be able to justify. That friction is the type system forcing you to notice when private data is about to become public — useful precisely because non-membership proofs are exactly where you might absent-mindedly leak it. The cryptographic hiding and the type-system hiding are two different things; this pattern relies on the cryptographic one. Domain separation — different hash prefixes for different purposes — prevents cross-contract correlation.

Why this counts as non-membership

If it looks stupid but it works, it isn’t stupid. — Murphy’s Laws of Combat
Le mieux est l’ennemi du bien. — Voltaire

The Set of spent nullifiers is the “used” list. The circuit proves the user’s nullifier isn’t in it. But the check is simple Compact code: Set.member() returns false — not a ZKP of non-membership in the cryptographic sense. The privacy comes from the nullifier being unlinkable to any identity, not from hiding the set query.

The contract details…

The core of performAction — derive a nullifier, check it’s not spent, then record it:

const nul = deriveNullifier(sk, action);

// NON-MEMBERSHIP CHECK: nullifier must NOT be in the set.
// `assert` is implicitly disclosing, so no wrapper is needed here.
assert(!spentNullifiers.member(disclose(nul)), "Action already performed");

// Record the nullifier (future attempts will fail)
spentNullifiers.insert(disclose(nul));

Full contract: contracts/nullifier-pattern.compact

Does it scale?

Spent nullifiers accumulate forever and that’s by design, not a defect. Pruning a spent nullifier would let the same secret replay the same action, which is exactly what the pattern is meant to prevent. The real question is whether proof cost grows with the set. Compact’s Set<T> ADT gives amortized constant-time member(), insert(), and remove(), so the contract pays the same circuit cost whether spentNullifiers holds 10 entries or 10 million. The on-chain set grows linearly with use, but the per-proof cost is flat.

That’s the practical edge over older accumulator-based nullifier schemes (Zcash-style, where non-membership requires an in-circuit cryptographic accumulator proof). On Midnight, the chain just queries its own Set — no accumulator, no path proof, no witness fetch of the set. Storage growth is real but cheap, and pruning isn’t possible without losing the security property: a removed entry could be replayed.

Privacy analysis

Visible on-chainHidden
A nullifier was insertedWhich member acted
A Merkle root check passedThe secret key
Which action type was performedThe link between identity and nullifier

When to use this

This pattern fits whenever the question is “has this identity performed action X before?” Use cases include voting, credential redemption, airdrop claims, rate limiting. This pattern handles unlimited set sizes, requires no off-chain infrastructure, and uses only native Compact primitives.

It does NOT work for “is this identity absent from an externally maintained list?” For that type of pattern, you need one of the next three patterns. Read carefully to understand which, when.


Pattern 2: Attribute-Based Blocklist Exclusion

I am not a crook. — Richard Nixon
The class of all classes which are not members of themselves. — Bertrand Russell

Exhaustive inequality checking is the tagline for this pattern and I mean exhaustive (or maybe exhausting). In this pattern the defined blocklist stores hashes of blocked attribute values, such as revoked credential IDs, sanctioned jurisdiction codes, or flagged device fingerprints. The user proves their attribute hash (derived inside the circuit from a witness-provided credential) doesn’t match any entry.

How it works

  1. The admin publishes attribute hashes to a public on-chain Set<Bytes<32>>
  2. A user holds a credential with an attribute (witness-provided, never disclosed)
  3. A circuit hashes the attribute with the same domain separator used to create blocklist entries
  4. The circuit iterates over each entry and asserts inequality
  5. If the attribute hash matches any entry, the assertion fails and no valid proof exists

The attribute value never leaves the proof. The blocklist is public so you can see what is blocked, but not who just proved they aren’t on the list.

The tradeoffs

I’ve got a little list — they’d none of ‘em be missed! — Ko-Ko, The Mikado
There is no royal road to geometry. — Euclid

This is the naive translation of “prove a negative.” The code asserts inequality against every entry, in-circuit. Circuit cost grows linearly with blocklist size. Each entry adds a hash comparison to the proof. At 8 entries that’s negligible; at 100, noticeable; past 1,000 it’s the reason the rest of this post exists. Also, the blocklist is fully public — that’s a feature (transparency about who’s blocked) but it limits the pattern to cases where the blocklist contents can be public.

The contract details…

The core of proveNotBlocked — hash the user’s credential attribute, then check it against every blocklist entry:

// Hash the user's credential attribute (private — stays in the proof)
const attribute = getCredentialAttribute();
const myAttrHash = hashAttribute(attribute);

// NON-MEMBERSHIP CHECK: assert attribute hash != each active entry
for (const i of 0..8) {
  const entry = blocked[i];
  if (disclose(i < count)) {
    assert(myAttrHash != entry, "Attribute is on the blocklist");
  }
}

Full contract: contracts/blocklist-exclusion.compact

Scaling the blocklist approach

The sample code above caps at 8 entries. To handle larger lists:

  • Increase the vector size: Vector<32, Bytes<32>> handles 32 entries, Vector<64, Bytes<32>> handles 64. Each doubles the comparison gates in the proof.
  • Batch across transactions: Split a 200-entry blocklist into 8 batches of 25. The user proves non-membership against each batch in separate transactions. A Map<Uint<64>, Boolean> tracks which batches have been cleared. This gets rather slow and cumbersome. I cannot recommend it. Seriously, do you want to DoS your own contract?
  • Hierarchical hashing: Hash the blocklist into a Merkle tree and use the sparse or indexed Merkle tree pattern (Patterns 3 and 4) for the actual non-membership proof. This is the crossover point where those patterns become worth the complexity.

When should I use this pattern?

Good for small, stable, public blocklists — jurisdiction screening, credential revocation from a small issuer, device attestation against a known small set. No off-chain infrastructure, no custom data structures. Just a loop and some comparisons.


Pattern 3: Sparse Merkle Tree Non-Membership

Space is big. Really big. You just won’t believe how vastly, hugely, mind-bogglingly big it is. — Douglas Adams
The eternal silence of these infinite spaces frightens me. — Blaise Pascal

Please read this section as the ‘pedagogical version’. Pattern 4 (Indexed Merkle Tree, below) is what a production system should actually use. I included Pattern 3 in full because the indexed-tree fix only makes sense once you’ve seen the naive sparse approach it improves on: same on-chain footprint, same trust model, but the proof size scales with set size instead of key-space, and the birthday-paradox footgun goes away. So… don’t bother implementing this one and, if you already know about the issues, jump straight to Pattern 4.

The textbook technique. A sparse Merkle tree has 2^D leaf positions, each either occupied (element present) or empty (default value). Non-membership means the leaf at position H(element) is empty. An authentication path lets the circuit verify this against the on-chain root.

The architecture

graph LR
    subgraph On-chain
        root["sparseRoot<br/>(Bytes&lt;32&gt;)"]
    end

    subgraph Off-chain Service
        tree["Full sparse MT<br/>(all 2^D leaves)"]
    end

    subgraph User's Wallet
        witness["Witness:<br/>- leaf<br/>- siblings<br/>- directions"]
        proof["ZK proof:<br/>path valid,<br/>leaf empty,<br/>root matches"]
    end

    tree -->|update root| root
    tree -->|auth path| witness
    witness --> proof
    proof -.->|verify against| root

The tree data isn’t the secret, the query is. The ZK proof hides which position was checked. An observer learns only that “someone proved non-membership for something.”

The Compact Approach: folding

Compact uses const for all bindings so you can’t accumulate a running hash through a loop with reassignment. But Compact’s built-in fold expression carries an accumulator across a vector traversal, exactly like Compact’s own merkleTreePathRoot implementation. The circuit folds from leaf to root in one expression:

const computedRoot = fold(
  (acc: Bytes<32>, sibling: Bytes<32>, goesRight: Boolean) =>
    goesRight ? hashNode(sibling, acc) : hashNode(acc, sibling),
  leafValue,
  siblings,
  directions
);

Multi-vector fold iterates over siblings and directions in lockstep. The accumulator starts at leafValue and hashes upward through each level. No pre-computed intermediates needed from the witness because the circuit computes the root directly.

The contract

proveNonMembership and proveMembership share an internal helper that does the member check, fetches the proof, folds, and confirms the root. Each public circuit then adds its own one-line assertion on the leaf value:

export circuit proveNonMembership(element: Bytes<32>): [] {
  const leafValue = verifySparseMemberAndPath(element);
  assert(leafValue == pad(32, ""), "Leaf is not empty — element IS in the set");
  proofCount.increment(1);
}

export circuit proveMembership(element: Bytes<32>): [] {
  const leafValue = verifySparseMemberAndPath(element);
  assert(leafValue != pad(32, ""), "Leaf is empty — element is NOT in the set");
  proofCount.increment(1);
}

Full contract: contracts/sparse-merkle-non-membership.compact

A footgun: position collisions

The position derivation above takes the first D bits of persistentHash(element). At depth 20, that’s 2^20 ≈ 1M positions — sounds like more than enough, until the birthday-paradox footgun kicks in around √(2^20) ≈ 1,000 inserts. Past that point, two distinct elements will eventually hash to the same leaf position.

The effect is not a security break. An attacker cannot trick the chain into accepting X as “not in the set” once X has been added: the leaf at position(X) is non-empty, so the proof fails. That direction is sound.

The break runs the other way: legitimate non-membership proofs get rejected. If Y was inserted first at position p, and a user later tries to prove non-membership of X where position(X) == p, the leaf isn’t empty. It holds Y’s marker and the proof fails even though X genuinely isn’t in the set. A false rejection. This is rarer with deeper trees, but rarer is not zero, and a stuck user is a stuck user.

The seemingly simple fix is to use the full hash bit-width as the path (depth = 256), but that produces a tree nobody wants to prove. The real fix is a different layout, which is what production systems use.

So you want to go into Production…

The fix for the footgun is to skip prefix-sparse entirely and reach for a different structure: an indexed Merkle tree. Leaves are sorted by key and each stores a pointer to the next-larger key. Non-membership of X is shown by exhibiting the predecessor leaf low where low.key < X < low.nextKey. Tree depth scales with the number of active entries, not the key bit-width — log(1M) ≈ 20 levels for a million entries, regardless of hash size.

The benefits stack: no collision risk (each leaf stores its actual key, so positions are unique by construction), shallower proofs, smaller witnesses, no false rejections. The cost lives in insertion — each new entry requires two Merkle updates: the predecessor’s nextKey pointer, plus the new leaf written with the predecessor’s old nextKey.

Pattern 4 below implements this layout in Compact. Read this as the teaching version. Pattern 4 is the same idea, with the footgun removed.

The off-chain service

The contract above assumes an off-chain service that:

  1. Maintains the full sparse Merkle tree. For depth 20, this is up to 1M leaves. In practice, sparse trees are stored compactly. Only non-empty subtrees are materialized, with default hashes computed lazily.

  2. Serves authentication paths. Given an element, the service hashes it to find the leaf position, then returns the leaf value, sibling hashes, and path directions.

  3. Updates the on-chain root. When elements are added or removed, the service recomputes the root and calls updateRoot().

This off-chain service is a trust point: it must return correct paths, and it learns which element is being queried. Mitigations include running the service locally, fetching the entire tree, or querying via a private information retrieval protocol.

Trust model

Trust, but verify. — Ronald Reagan
Covenants, without the sword, are but words. — Thomas Hobbes

ConcernRiskMitigation
Service availabilityUsers can’t generate proofs if service is downReplicate tree data, allow local copies
Path correctnessMalicious service returns wrong siblingsCircuit catches this — proof verification fails
Query privacyService sees which element is checkedFetch entire tree locally; use PIR
Root integrityAdmin could set a fraudulent rootAdd root update proofs (old + new root verification)
Update authoritySingle admin controls the setMulti-sig admin or governance-controlled updates

When to use this

When the set is too large for exhaustive comparison (Pattern 2) and the non-membership question is about an external set rather than a self-generated nullifier (Pattern 1) — that’s when you reach for this pattern. Think sanctions lists with thousands of entries, large-scale credential revocation, or any scenario where you need to prove absence from an arbitrary, externally managed set. In many cases these lists are not actually private, which makes your life easier.

The cost is real: you need off-chain infrastructure, a trusted (or verifiable) admin for root updates, and the circuit is heavier than the other patterns. But it’s the only approach that scales to large sets, and Pattern 4 below is the variant that actually scales without the collision footgun.


Pattern 4: Indexed Merkle Tree Non-Membership

Between two evils, I always pick the one I never tried before. — Mae West
Order is heaven’s first law. — Alexander Pope, Essay on Man, Epistle IV

This pattern has the same on-chain footprint as Pattern 3 — just a root and a member registry — but the off-chain tree is sorted by key, and each leaf stores a pointer to the next-larger key. Non-membership becomes a range bracket: show the predecessor leaf whose key is less than the target and whose nextKey is greater.

How it works

  1. The off-chain tree starts with a sentinel leaf at position 0: { key: 0, nextKey: 2^248 - 1 }. Every real key is then spliced in between its predecessor and whatever that predecessor’s nextKey used to be.
  2. When the admin inserts K, the tree:
    • Finds the predecessor low where low.key < K < low.nextKey
    • Rewrites low’s nextKey to K
    • Writes a new leaf { key: K, nextKey: low's old nextKey } at the next free position
  3. The admin pushes the new root on-chain via updateRoot().
  4. For non-membership of T, the user’s witness returns the predecessor leaf and a Merkle path. The circuit checks:
    • low.key < T < low.nextKey (range bracket: both compares are native Uint<248>)
    • The predecessor leaf hashes into the on-chain root (fold)

If both hold, T can’t be in the set: the sorted ordering would require it to sit between low and low.nextKey, but low’s nextKey is some other value, not T.

This fixes Pattern 3’s collision problem

Pattern 3 derived tree position from hash(element). Collisions hurt liveness; false rejections happen at scale. Pattern 4 stores each key in its own leaf with no positional hashing. Every key occupies exactly one position; positions are unique by construction. Tree depth scales with the number of active entries, not the key bit-width.

Key encoding

Compact’s < operator works on Uint<N> but not on Field or Bytes<32>. So keys are typed as Uint<248> — 31 bytes of entropy. Users derive a key by truncating a 256-bit hash to 248 bits; the collision bound is roughly 2^124, far past anything realistic.

The contract

struct IndexedLeaf {
  key: Uint<248>,
  nextKey: Uint<248>
}

witness getPredecessorProof(target: Uint<248>):
  [IndexedLeaf, Vector<8, Bytes<32>>, Vector<8, Boolean>];

export circuit proveNonMembership(target: Uint<248>): [] {
  // ... member check (same as Pattern 3) ...

  const proof = getPredecessorProof(target);
  const low = proof[0];
  const siblings = proof[1];
  const directions = proof[2];

  // Range bracket — native Uint<248> compares
  assert(low.key < target,       "Predecessor key >= target");
  assert(target < low.nextKey,   "Target >= predecessor's nextKey");

  // Verify the predecessor is in the tree (fold leaf to root)
  const leafHash = hashLeaf(low);
  const computedRoot = fold(
    (acc: Bytes<32>, sibling: Bytes<32>, goesRight: Boolean) =>
      goesRight ? hashNode(sibling, acc) : hashNode(acc, sibling),
    leafHash, siblings, directions
  );
  assert(computedRoot == indexedRoot, "Predecessor proof mismatch");

  proofCount.increment(1);
}

Full contract: contracts/indexed-merkle-non-membership.compact

Pattern 3 vs Pattern 4 at a glance

Pattern 3 (prefix-sparse)Pattern 4 (indexed)
Collision riskYes (birthday paradox)None
Tree depthKey bit-widthlog(active entries)
Per-proof witnessLeaf value + siblingsLeaf struct + siblings
Insertion logicHash to position, update one leafFind predecessor, update two leaves
Range comparisonNone neededTwo Uint<248> compares per proof

The insertion side is where Pattern 4 gets more involved. Pattern 3 inserts by hashing the element to a position and rewriting one leaf: one update, one new root. Pattern 4 has to find the predecessor low, rewrite its nextKey to point at the new key, and write a fresh leaf carrying low’s old nextKey. Two leaves change per insert, and the predecessor lookup has to happen first.

This demo takes a shortcut: the admin does all of that on their own machine, recomputes the new root, and posts it via updateRoot(). The contract trusts the new root without verifying it, which is the same trust model as Pattern 3’s updateRoot. If the admin lies, users get false non-membership answers until somebody catches it.

A production deployment removes that trust by making whoever inserts prove they did the work correctly. Three checks have to happen inside the circuit:

CheckWhat it proves
Predecessor inclusionThe leaf claimed to be low actually existed in the previous tree (Merkle path from low to the old root)
Rangelow.key < newKey < low.nextKey — the new key really belongs between these two
Two-leaf root transitionRewriting low.nextKey = newKey and adding a new leaf {newKey, low's old nextKey} produces exactly the new root being posted

With those three in a single proof, updateRoot() now takes a (newRoot, insertionProof) pair and the chain verifies the insertion was honest without trusting anyone. Building that circuit is a significant piece of work: multiple Merkle path verifications, range checks, and root recomputation all stitched together. I skip it here to keep this blog and demo short enough to read in one sitting (I’ve probably already written too much). Production code adds it back.

When to use this?

When you’d otherwise reach for Pattern 3, but you actually need to scale and be sure to avoid collisions. Dynamic revocation lists, sanctions screening at the 10k+ entry scale, anywhere “false rejection rate climbs with insertions” is unacceptable. Circuit cost is a bit heavier than Pattern 3, but that’s what we have to sacrifice to make the math work.

If the blacklist is published (sanctions, public CRLs, revoked-credential registries), you don’t need any off-chain service at all. You can just clone the list, build the tree locally, verify its root matches the on-chain root, and generate your own predecessor proof. The service in the diagram is a convenience, not a trust point. The only case where this gets genuinely harder is when the set’s contents must stay confidential from the prover too. Examples would be things like corporate blacklists, customer denylists, or deals where membership reveals business intel. That three-way hide (private element, private set, and neither side learning the other’s) needs polynomial commitments or purpose-built ZK sets, neither of which Compact exposes today.


Final Comparison

NullifierAttribute blocklistSparse Merkle treeIndexed Merkle tree
What it proves”I haven’t done X""My attribute isn’t in list L""Element E is not in set S""Key K is not in set S”
Set ownerSelf-generatedAdmin-maintainedAdmin-maintainedAdmin-maintained
Set visibilityPublic (nullifier hashes)Public (attribute hashes)Root only (contents hidden)Root only (contents hidden)
Max set sizeUnlimited~32 (circuit cost)2^depth (collisions at √(2^depth))2^depth (no collisions)
Off-chain infraNoneNoneTree service requiredSorted-leaf service required
Circuit costO(1) per checkO(n) in list sizeO(depth) per proofO(depth) + 2 range compares
Compact supportNative (Set, persistentHash)Native (loop + comparison)Manual (fold)Manual (fold + Uint<248>)
False rejectionsNoneNoneYes (birthday paradox)None
Production readyYesYesTeaching version onlyYes (admin insert), proof of insert is future work

Which pattern fits which problem

For every complex problem there is an answer that is clear, simple, and wrong. — H. L. Mencken

The comparison table above lays out the dimensions. This one collapses them into a decision: given a use case, which pattern do you reach for?

Use casePattern
Anonymous voting (one vote per registered voter)Pattern 1 (nullifier)
Coupon, ticket, or claim redemptionPattern 1 (nullifier)
Private double-spend preventionPattern 1 (nullifier)
Jurisdiction screening against a small sanctions listPattern 2 (blocklist)
Credential revocation for a small issuer (≤~32 active revocations)Pattern 2 (blocklist)
Teaching the sparse-Merkle non-membership ideaPattern 3
Large dynamic revocation list, public keyPattern 4 (indexed Merkle)
Sanctions screening with thousands of entriesPattern 4 (indexed Merkle)
Non-membership of a private element in a public setPattern 4 wrapped in a higher-level protocol
Constant-size proofs at any scaleCryptographic accumulators (RSA, KZG) — not Compact-native today

All Kidding Aside

In theory, there is no difference between theory and practice. In practice, there is. — Yogi Berra
Seek simplicity, and distrust it. — Alfred North Whitehead

Non-membership proofs on Midnight work today, with some tradeoffs. The nullifier pattern handles voting, credential single-use, and double-spend prevention without any extra moving parts. Blocklist exclusion handles modest-sized sets with nothing more than a loop. The sparse Merkle tree gets the basic idea across but ships a birthday-paradox footgun. Indexed Merkle is what production systems landed on, and Pattern 4 brings it into Compact.

All four contracts compile against Compact v0.31.0. The demo is running on Midnight Preproduction. Please note that these contracts are starting points, not production code. A real deployment needs hardened witness implementations, off-chain services (for Patterns 3 and 4), in-circuit insertion proofs for Pattern 4, and someone willing to sit with the privacy assumptions long enough to break them before an attacker does.

All source code — contracts, witnesses, off-chain trees, and a sparse Merkle tree visualizer — is at github.com/verifiably-human/non-membership.