DEV Community

Tosh
Tosh

Posted on

Token Gates on Midnight: Verifying Asset Ownership and Membership with ZK Proofs

Token Gates on Midnight: Verifying Asset Ownership and Membership with ZK Proofs

Token gating is one of the oldest tricks in the Web3 playbook — hold a token, get access. Simple in theory, leaky in practice. When you prove NFT ownership on Ethereum to access a Discord server, you're revealing your wallet address, your full token history, and every other asset you hold. The "gate" works, but the privacy cost is total.

Midnight changes the equation. On Midnight, you can prove you own a qualifying token — without revealing which token, which wallet, or anything beyond the bare minimum the gate requires. The ZK proof is the credential. Nothing else leaks.

This article walks through three concrete patterns for token-gated access on Midnight, with Compact pseudocode for each. If you've read the Midnight docs and understand the witness/public distinction, these patterns should feel immediately buildable.


The Core Problem: Proving Without Revealing

On a transparent chain, "prove you own a token" is easy — just sign a message with the wallet that holds it. The verifier sees your address, looks up your balance, done. But they also see everything else about you.

Midnight flips this. The chain stores commitments, not balances. Your ownership of an asset is represented as a private state entry — a leaf in a shielded state tree. To prove ownership, you generate a ZK proof that says:

"I know a secret (a witness) such that, when committed to on-chain state, it corresponds to a valid token holding that satisfies the gate condition."

The verifier (another circuit, or a public contract method) checks the proof without ever learning your secret. No wallet address. No token ID. No balance amount beyond what the gate requires.

Three things make this possible on Midnight:

  1. Witnesses — private inputs to circuits that never appear on-chain
  2. Commitments — on-chain hashes of private state that the circuit proves are valid
  3. Nullifiers — single-use spend tags that prevent replay without revealing identity

Let's build the patterns.


Pattern 1: Proving ERC-20 Balance Above Threshold

The simplest gate: "you hold at least X tokens of type T." Useful for DAO voting weight, premium content tiers, or staking requirements.

The trick is that you want to prove balance >= threshold without revealing balance. In Compact, this looks like a range proof embedded in the gate circuit.

// Token balance commitment structure
// Stored in ledger when user deposits/wraps tokens
ledger token_commitments: MerkleTree<32, Bytes<32>>;
ledger gate_threshold: Uint<64>;

circuit prove_balance_gate(
  // Private: what you know
  witness balance: Uint<64>,
  witness token_id: Uint<64>,
  witness salt: Bytes<32>,
  witness merkle_path: Uint<32>[],
  witness merkle_siblings: Bytes<32>[],

  // Public: what the verifier sees
  public commitment_root: Bytes<32>,
  public nullifier: Bytes<32>,
  public gate_id: Uint<32>
) -> public Bool {

  // 1. Reconstruct the commitment from private inputs
  let commitment = hash(token_id, balance, salt);

  // 2. Prove the commitment exists in the on-chain tree
  let computed_root = merkle_verify(
    commitment,
    merkle_path,
    merkle_siblings
  );
  assert computed_root == commitment_root;

  // 3. Prove balance meets threshold (range proof)
  assert balance >= gate_threshold;

  // 4. Derive nullifier to prevent double-use
  // nullifier = hash(salt, gate_id) — ties proof to this specific gate
  let expected_nullifier = hash(salt, gate_id);
  assert expected_nullifier == nullifier;

  return true;
}

contract BalanceGate {
  ledger used_nullifiers: Map<Bytes<32>, Bool>;

  // Called with the ZK proof + public inputs
  export transition verify_and_grant(
    proof: Proof,
    commitment_root: Bytes<32>,
    nullifier: Bytes<32>,
    gate_id: Uint<32>
  ): Bool {
    // Reject replays
    assert !used_nullifiers[nullifier];

    // Verify the ZK proof against public inputs
    assert verify_proof(
      prove_balance_gate,
      proof,
      [commitment_root, nullifier, gate_id]
    );

    // Mark nullifier used
    used_nullifiers[nullifier] = true;

    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

The salt matters. Without the salt in the commitment, an attacker who knew your token_id could brute-force commitments and link proofs. The salt makes commitments unlinkable.

The gate_id in the nullifier is intentional. It means the same token holding can generate valid proofs for multiple different gates, but each proof is single-use per gate. You're not burning a nullifier globally — just for that gate context.

The threshold is public ledger state. Gate operators can update it without requiring new circuits. The circuit reads gate_threshold from the ledger at proof time.


Pattern 2: NFT Membership Proof

NFT gates are stricter — you don't just need any balance, you need to own a token from a specific collection. The collection is identified by a collection_id, and the gate verifies membership without revealing which token ID you hold.

ledger nft_collection_root: Map<Uint<32>, Bytes<32>>;
// Maps collection_id -> merkle root of all token commitments in that collection

circuit prove_nft_membership(
  // Private
  witness token_id: Uint<64>,
  witness owner_secret: Bytes<32>,
  witness salt: Bytes<32>,
  witness merkle_path: Uint<32>[],
  witness merkle_siblings: Bytes<32>[],

  // Public
  public collection_id: Uint<32>,
  public commitment_root: Bytes<32>,
  public nullifier: Bytes<32>
) -> public Bool {

  // Commit to ownership: hash(owner_secret, token_id, collection_id, salt)
  // owner_secret is derived from user's private key — proves you control it
  let ownership_leaf = hash(owner_secret, token_id, collection_id, salt);

  // Prove this leaf exists in the collection's commitment tree
  let computed_root = merkle_verify(
    ownership_leaf,
    merkle_path,
    merkle_siblings
  );
  assert computed_root == commitment_root;

  // commitment_root must match the on-chain collection root
  assert commitment_root == nft_collection_root[collection_id];

  // Nullifier prevents same token proving twice for same gate
  assert hash(owner_secret, collection_id) == nullifier;

  return true;
}

contract NFTGate {
  ledger used_nullifiers: Map<Bytes<32>, Bool>;

  export transition verify_member(
    proof: Proof,
    collection_id: Uint<32>,
    commitment_root: Bytes<32>,
    nullifier: Bytes<32>
  ): Bool {
    assert !used_nullifiers[nullifier];

    assert verify_proof(
      prove_nft_membership,
      proof,
      [collection_id, commitment_root, nullifier]
    );

    used_nullifiers[nullifier] = true;
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

The key difference from Pattern 1: the ownership leaf binds to owner_secret, which is derived from the user's private key off-chain. This ensures that even if someone learns your token ID, they can't prove ownership — they need the secret too. The circuit proves both "this token exists in this collection" and "I control the private key associated with it."

Collection root updates are handled by a trusted minter or oracle that adds new token commitments to the Merkle tree when tokens are minted. This is the main coordination point — Midnight doesn't have a native NFT standard yet, so the minting contract needs to maintain the commitment tree.


Pattern 3: Allowlist Membership via Merkle Inclusion Proof

Sometimes you don't want a fungible gate at all — you want a curated allowlist. Think: early access programs, KYC-verified participants, invited collaborators. The list is maintained off-chain (or on-chain as a Merkle root), and users prove inclusion without revealing which slot they occupy.

ledger allowlist_root: Bytes<32>;
// Set by contract admin — Merkle root of all allowed commitments

circuit prove_allowlist_membership(
  // Private
  witness identity_secret: Bytes<32>,
  witness list_slot: Uint<32>,        // which slot in the list
  witness merkle_path: Uint<32>[],
  witness merkle_siblings: Bytes<32>[],

  // Public
  public allowlist_root_input: Bytes<32>,
  public nullifier: Bytes<32>,
  public context_id: Uint<32>         // ties proof to specific use context
) -> public Bool {

  // Identity leaf: what was added to the allowlist at enrollment
  // = hash(identity_secret, list_slot)
  // The admin received a commitment from the user at signup — never the secret
  let identity_leaf = hash(identity_secret, list_slot);

  // Prove inclusion in the allowlist tree
  let computed_root = merkle_verify(
    identity_leaf,
    merkle_path,
    merkle_siblings
  );
  assert computed_root == allowlist_root_input;
  assert allowlist_root_input == allowlist_root;

  // Context-bound nullifier — prevents using same slot for same context twice
  assert hash(identity_secret, context_id) == nullifier;

  return true;
}

contract AllowlistGate {
  ledger admin: Bytes<32>;
  ledger allowlist_root: Bytes<32>;
  ledger used_nullifiers: Map<Bytes<32>, Bool>;

  export transition update_allowlist_root(new_root: Bytes<32>): void {
    assert caller == admin;
    allowlist_root = new_root;
  }

  export transition verify_allowlisted(
    proof: Proof,
    allowlist_root_input: Bytes<32>,
    nullifier: Bytes<32>,
    context_id: Uint<32>
  ): Bool {
    assert !used_nullifiers[nullifier];
    assert verify_proof(
      prove_allowlist_membership,
      proof,
      [allowlist_root_input, nullifier, context_id]
    );
    used_nullifiers[nullifier] = true;
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is the most privacy-preserving of the three. The admin never learns which slot a user is proving — only that they're in the list. The list_slot stays entirely private.

Enrollment flow: At signup, users generate an identity_secret locally and submit hash(identity_secret, list_slot) to the admin. The admin adds this commitment to the Merkle tree and publishes the new root. The user stores their secret locally. At gate time, they generate the proof client-side and submit it.

Root updates are atomic. When the admin adds new members and publishes a new root, existing members can still generate valid proofs — the circuit verifies against the current allowlist_root on-chain, but users with old Merkle paths will fail. They need an updated path against the new root. Client tooling needs to handle this gracefully (re-fetch the Merkle path whenever the root changes).


Putting It Together: Gated Access Circuit

Here's a more complete contract that combines patterns — a DAO voting contract where voting rights require proving either a balance threshold or NFT membership:

contract DAOVoting {
  ledger proposal_count: Uint<32>;
  ledger votes: Map<Uint<32>, Map<Bool, Uint<64>>>;   // proposal_id -> yes/no -> count
  ledger vote_nullifiers: Map<Bytes<32>, Bool>;
  ledger balance_gate_root: Bytes<32>;
  ledger membership_gate_root: Bytes<32>;
  ledger balance_threshold: Uint<64>;
  ledger collection_id: Uint<32>;

  export transition vote(
    proposal_id: Uint<32>,
    vote_yes: Bool,
    gate_type: Uint<8>,        // 0 = balance proof, 1 = NFT proof
    proof: Proof,
    commitment_root: Bytes<32>,
    nullifier: Bytes<32>
  ): void {
    assert proposal_id < proposal_count;
    assert !vote_nullifiers[nullifier];

    if gate_type == 0 {
      // Verify balance-based voting right
      assert verify_proof(
        prove_balance_gate,
        proof,
        [commitment_root, nullifier, proposal_id]
      );
      assert commitment_root == balance_gate_root;
    } else {
      // Verify NFT membership voting right
      assert verify_proof(
        prove_nft_membership,
        proof,
        [collection_id, commitment_root, nullifier]
      );
      assert commitment_root == membership_gate_root;
    }

    vote_nullifiers[nullifier] = true;

    if vote_yes {
      votes[proposal_id][true] = votes[proposal_id][true] + 1;
    } else {
      votes[proposal_id][false] = votes[proposal_id][false] + 1;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The nullifier here is scoped to proposal_id — the same token can vote on multiple proposals, but only once per proposal. This is the right behavior for a DAO: one member, one vote per question, but retained membership across questions.


Real Use Cases Worth Building

DAO Voting Rights — exactly the pattern above. Midnight DAOs can have fully anonymous votes where even the DAO doesn't know who voted for what, only that qualified members voted. Vote tallies are public; voter identity is not.

Subscriber Access — a content platform issues subscriber tokens off-chain (or on-chain with shielded minting). Readers prove subscription to access content without the platform learning their wallet. Combine with a session token (short-lived nullifier) for practical web UX.

Age Verification via Token — a KYC provider issues age-attestation tokens to verified users. A regulated service gates access by requiring proof of holding such a token. The service never sees the user's identity or KYC data — just the proof that a verified credential exists. This is genuinely useful for compliant DeFi without surveillance.

Event Ticketing — ticket NFTs with ZK gate proofs. You prove you hold a ticket without revealing your wallet. The gate marks the nullifier used (like scanning a QR code), preventing replay. Ticket resale can be handled by transferring the owner_secret — though that requires careful UX design around secret custody.

Allowlist Early Access — the Pattern 3 case. New product launches, beta programs, or airdrop eligibility checks. Users on the list prove inclusion without the gate operator learning which users have already claimed access (unless they want that, in which case they track nullifiers publicly).


Implementation Notes

A few things that will bite you in practice:

Merkle path staleness. Every time the commitment tree is updated, existing Merkle paths are invalid for the new root. Your client SDK needs to re-fetch paths or cache them with a TTL tied to the root hash. This is the most common source of proof failures in testing.

Nullifier scope. Decide early whether nullifiers are global (one use ever) or scoped (one use per context). Global nullifiers are safer but limit composability — the same credential can only be used once, anywhere. Scoped nullifiers (hash(secret, context_id)) allow the same credential to gate access to multiple independent systems.

Proof generation time. On current Midnight testnet infrastructure, circuit proof generation runs client-side in the browser or node process. For the patterns above, expect 2-10 seconds depending on Merkle tree depth. Design your UX to handle this — a spinner is fine, a timeout is not.

Commitment scheme consistency. All three patterns use hash(...) — make sure you're using the same hash function (Poseidon is standard in ZK contexts) throughout. Mixing SHA256 for commitments and Poseidon for nullifiers will produce incorrect proofs silently until you debug for an hour.


Token gating on Midnight isn't just "more private" than traditional approaches — it's a qualitatively different trust model. The gate operator learns nothing about you except that you qualify. That's a meaningful upgrade for any system that needs access control without surveillance.

The three patterns here — balance threshold, NFT membership, Merkle allowlist — cover the majority of real gating use cases. Mix and match them as circuit variants in a single contract, and you have a general-purpose membership primitive that can underpin DAOs, content platforms, and compliant applications alike.

Top comments (0)