DEV Community

Tosh
Tosh

Posted on

Replay Attack Prevention in Compact: Nonces, Nullifiers & Domain Separation

Replay Attack Prevention in Compact: Nonces, Nullifiers & Domain Separation

A replay attack is when a valid transaction gets submitted more than once — or a valid proof gets reused in a different context than it was created for. In Ethereum, this is largely handled by the nonce in every transaction and chain IDs in EIP-155. In Midnight's ZK contract model, the problem is subtler and the solutions require more deliberate design.

Because ZK proofs are self-contained — they don't carry inherent transaction nonces — a proof generated for one purpose can potentially be submitted multiple times, or in a modified context, if your contract doesn't explicitly prevent it. This guide covers three mechanisms for replay prevention in Compact, and when to use each.


The Problem in Concrete Terms

Imagine a simple airdrop contract: users call claimAirdrop() and get 100 MNT. The circuit verifies they're on an allowlist and sends the coins. If there's no replay prevention:

  1. Alice calls claimAirdrop() — generates a valid ZK proof, submits transaction, receives 100 MNT
  2. Alice submits the same proof again — it's still valid, the circuit logic is satisfied, she receives another 100 MNT
  3. Repeat until the vault is empty

The proof is valid because nothing in the circuit state changed from the proof's perspective. This is the fundamental replay problem.

There are three main solutions, each appropriate for different situations:


Mechanism 1: Counter-Based Nonces

The simplest approach: store a counter in the ledger that increments with every privileged operation. Include the current counter value as a constraint in the circuit.

contract NonceProtected {
  ledger operationCount: Uint<64>;
  ledger balance: Uint<64>;

  circuit withdraw(
    witness ownerSecret: Bytes<32>,
    public expectedNonce: Uint<64>,
    recipient: ZswapCoinPublicKey,
    amount: Uint<64>
  ): [] {
    // Verify this matches the current sequence number
    assert expectedNonce == ledger.operationCount : "nonce mismatch";
    assert verifyCommitment(ownerSecret, ledger.ownerCommitment) : "unauthorized";
    assert amount <= ledger.balance : "insufficient funds";

    // Increment BEFORE sending to prevent re-entrancy
    ledger.operationCount = ledger.operationCount + 1;
    ledger.balance = ledger.balance - amount;
    sendShielded(recipient, ledger.heldCoin, amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

A proof generated with expectedNonce = 5 is only valid when ledger.operationCount == 5. After the first successful execution, the counter is 6, so replaying the proof fails the nonce check.

When to Use Nonces

Counter nonces work well for:

  • Sequential operations where order matters (version-locked upgrades, state machines)
  • Per-account nonces where each user has their own counter
  • Rate limiting where you want to count operations and enforce limits
contract PerUserNonce {
  ledger userNonces: Map<ZswapCoinPublicKey, Uint<64>>;

  circuit userAction(
    pk: ZswapCoinPublicKey,
    witness secret: Bytes<32>,
    public nonce: Uint<64>
  ): [] {
    const currentNonce = ledger.userNonces.getOr(pk, 0);
    assert nonce == currentNonce : "invalid nonce";
    assert verifyCommitment(secret, ledger.userCommitments.get(pk)) : "unauthorized";

    ledger.userNonces.set(pk, currentNonce + 1);
    // ... do the action
  }
}
Enter fullscreen mode Exit fullscreen mode

Limitations of Nonces

Nonces have a subtle issue in Midnight: because ZK proofs are generated before the transaction is included in a block, if two transactions are in-flight simultaneously, they'll both try to use the same nonce value. Whichever lands first succeeds; the other fails with "nonce mismatch" and must be regenerated.

This means nonce-based contracts serialize operations — you can't do concurrent writes. For high-throughput contracts, this is a bottleneck. That's where nullifiers come in.


Mechanism 2: Set-Based Nullifiers

Nullifiers are one-time-use tokens derived from a secret. Instead of a single incrementing counter, you maintain a set of "spent" nullifiers. Before processing an operation, you check that the operation's nullifier hasn't been seen before — then add it to the spent set.

The key insight: the nullifier itself can be computed by the prover without revealing the underlying secret, and the derivation uses persistentCommit so it's bound to both the secret and the specific context.

contract NullifierProtected {
  ledger spentNullifiers: MerkleTree<24, Bytes<32>>;
  ledger nullifierCount: Uint<64>;
  ledger balance: Uint<64>;

  circuit claimAirdrop(
    witness claimSecret: Bytes<32>,
    public nullifier: Bytes<32>,
    recipient: ZswapCoinPublicKey
  ): [] {
    // Verify the nullifier was correctly derived from the secret
    assert nullifier == persistentCommit(
      claimSecret.concat(bytes("airdrop-v1"))
    ) : "invalid nullifier";

    // Verify this nullifier hasn't been used
    assert !ledger.isNullifierSpent(nullifier) : "already claimed";

    // Verify the secret is in the allowlist
    assert verifyAllowlist(claimSecret, ledger.allowlistRoot) : "not in allowlist";

    // Mark nullifier as spent BEFORE sending
    const index = ledger.nullifierCount;
    ledger.spentNullifiers.set(index, nullifier);
    ledger.nullifierCount = index + 1;

    sendShielded(recipient, ledger.airdropCoin, 100_000_000); // 100 MNT
  }

  circuit isNullifierSpent(nullifier: Bytes<32>): [Boolean] {
    // In practice this would do a Merkle membership check
    for i in 0..ledger.nullifierCount {
      if ledger.spentNullifiers.get(i) == nullifier {
        return [true];
      }
    }
    return [false];
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Works

The proof of work here:

  1. The prover generates nullifier = persistentCommit(claimSecret, "airdrop-v1")
  2. The circuit verifies this derivation in-circuit (the assert nullifier == persistentCommit(...) line)
  3. The nullifier is added to a public on-chain set
  4. Future proofs with the same claimSecret will derive the same nullifier, which is now in the spent set

What an attacker sees: the nullifier (public), not the secret. They can't compute a different valid nullifier from the same secret. They can't generate a valid proof with a different secret unless they're on the allowlist.

Crucially, multiple users can claim simultaneously — there's no global counter to serialize on. Each user's nullifier is independent.

Compact Nullifier Pattern with persistentCommit

The standard pattern uses persistentCommit with a context string to generate nullifiers:

// Good: domain-separated nullifier
const nullifier = persistentCommit(
  secret.concat(contractAddress().toBytes()).concat(bytes("claim-v1"))
);

// Bad: no domain separation — same nullifier in different contracts
const nullifier = persistentCommit(secret);
Enter fullscreen mode Exit fullscreen mode

Always include a domain string. Without it, a nullifier spent in one contract could be replayed as proof-of-spending in another contract that checks for nullifier existence.


Mechanism 3: Domain Separation

Domain separation is a technique for preventing cross-context replay — situations where a proof generated for one purpose is valid in a different (unintended) context.

The core idea: when hashing or committing values, include a unique tag that identifies the specific operation, contract version, and chain. This makes proofs from one domain cryptographically incompatible with another.

// Define domain tags as constants
const DOMAIN_VOTE = bytes("midnight:voting-v1:cast-vote");
const DOMAIN_DELEGATE = bytes("midnight:voting-v1:delegate");
const DOMAIN_REVOKE = bytes("midnight:voting-v1:revoke-delegation");

contract VotingContract {
  ledger commitments: MerkleTree<20, Bytes<32>>;
  ledger votes: Map<Bytes<32>, Uint<8>>;  // nullifier -> choice

  circuit castVote(
    witness voterSecret: Bytes<32>,
    public nullifier: Bytes<32>,
    public choice: Uint<8>
  ): [] {
    // Domain-separated nullifier
    assert nullifier == persistentCommit(
      voterSecret.concat(DOMAIN_VOTE)
    ) : "invalid vote nullifier";

    assert !ledger.votes.has(nullifier) : "already voted";
    assert choice >= 0 && choice <= 2 : "invalid choice";  // 0=yes, 1=no, 2=abstain

    // Verify voter is registered (Merkle membership proof)
    // ... membership check omitted for brevity

    ledger.votes.set(nullifier, choice);
  }

  circuit delegateVote(
    witness voterSecret: Bytes<32>,
    public nullifier: Bytes<32>,
    delegate: ZswapCoinPublicKey
  ): [] {
    // Different domain tag — delegation nullifier ≠ vote nullifier
    assert nullifier == persistentCommit(
      voterSecret.concat(DOMAIN_DELEGATE)
    ) : "invalid delegation nullifier";

    assert !ledger.delegations.has(nullifier) : "already delegated";
    ledger.delegations.set(nullifier, delegate);
  }
}
Enter fullscreen mode Exit fullscreen mode

Without domain separation, the same voterSecret generates the same nullifier for both castVote and delegateVote. If you try to delegate after voting (or vote after delegating), the circuits might incorrectly accept or reject based on shared nullifier state.

With domain separation, voting and delegation produce different nullifiers from the same secret. You can vote and delegate independently.

Cross-Contract Domain Separation

Include the contract address in your domain tag to prevent a nullifier spent in Contract A from being valid in Contract B:

circuit getVoteNullifier(
  witness voterSecret: Bytes<32>
): [Bytes<32>] {
  return [persistentCommit(
    voterSecret
      .concat(ContractAddress.self().toBytes())  // bind to this contract
      .concat(bytes("vote-v1"))                  // operation tag
  )];
}
Enter fullscreen mode Exit fullscreen mode

This is especially important for token contracts — the same secret shouldn't be reusable across different token systems.


Real-World Example: A Replay-Safe Voting Contract

Here's a complete voting contract combining all three mechanisms:

import * from "stdlib";

const DOMAIN_VOTE = bytes("midnight:gov-vote-v2:cast");

contract GovernanceVote {
  // Proposal state
  ledger proposalId: Bytes<32>;
  ledger votingDeadline: Uint<64>;
  ledger yesVotes: Uint<64>;
  ledger noVotes: Uint<64>;
  ledger abstainVotes: Uint<64>;

  // Replay prevention: set of spent vote nullifiers
  ledger spentNullifiers: MerkleTree<20, Bytes<32>>;
  ledger nullifierCount: Uint<64>;

  // Voter registry: Merkle root of registered voter commitments
  ledger voterRegistryRoot: Bytes<32>;

  circuit initialize(
    proposalId: Bytes<32>,
    deadline: Uint<64>,
    voterRoot: Bytes<32>
  ): [] {
    assert ledger.proposalId == bytes("") : "already initialized";
    ledger.proposalId = proposalId;
    ledger.votingDeadline = deadline;
    ledger.voterRegistryRoot = voterRoot;
  }

  circuit castVote(
    // Public inputs (visible on-chain)
    public nullifier: Bytes<32>,
    public choice: Uint<8>,

    // Private witnesses (hidden from on-chain observers)
    witness voterSecret: Bytes<32>,
    witness merkleProof: MerkleProof<20>
  ): [] {
    // 1. Timing check (no replay in wrong window)
    assert currentBlockTime() <= ledger.votingDeadline : "voting closed";

    // 2. Verify nullifier derivation (domain-separated, bound to this proposal)
    const expectedNullifier = persistentCommit(
      voterSecret
        .concat(ledger.proposalId)           // proposal-specific
        .concat(ContractAddress.self().toBytes())  // contract-specific
        .concat(DOMAIN_VOTE)                  // operation-specific
    );
    assert nullifier == expectedNullifier : "invalid nullifier";

    // 3. Replay prevention: nullifier must not be spent
    assert !isSpent(nullifier) : "vote already cast";

    // 4. Membership: voter must be in registry
    const voterCommitment = persistentCommit(voterSecret);
    assert verifyMerkleProof(
      voterCommitment,
      merkleProof,
      ledger.voterRegistryRoot
    ) : "not a registered voter";

    // 5. Valid choice
    assert choice <= 2 : "invalid vote option";

    // 6. Record vote (update BEFORE external effects)
    recordNullifier(nullifier);
    if choice == 0 {
      ledger.yesVotes = ledger.yesVotes + 1;
    } else if choice == 1 {
      ledger.noVotes = ledger.noVotes + 1;
    } else {
      ledger.abstainVotes = ledger.abstainVotes + 1;
    }
  }

  // Helper: check if nullifier is in spent set
  circuit isSpent(nullifier: Bytes<32>): [Boolean] {
    const count = ledger.nullifierCount;
    for i in 0..count {
      if ledger.spentNullifiers.get(i) == nullifier {
        return [true];
      }
    }
    return [false];
  }

  // Helper: record a spent nullifier
  circuit recordNullifier(nullifier: Bytes<32>): [] {
    const index = ledger.nullifierCount;
    ledger.spentNullifiers.set(index, nullifier);
    ledger.nullifierCount = index + 1;
  }

  circuit getResults(): [Uint<64>, Uint<64>, Uint<64>] {
    return [ledger.yesVotes, ledger.noVotes, ledger.abstainVotes];
  }
}
Enter fullscreen mode Exit fullscreen mode

What Each Layer Prevents

Timing check (currentBlockTime() <= votingDeadline): Prevents valid proofs from being submitted after the vote closes. Even if Alice has a valid proof from before the deadline, submitting it late fails.

Proposal-bound nullifier (including ledger.proposalId): Prevents a voter's proof from being replayed in a different proposal vote. If the governance system runs two votes simultaneously, proofs are cryptographically isolated by proposal ID.

Contract-bound nullifier (including ContractAddress.self()): Prevents the nullifier from being valid in a different deployment of the same contract. Useful when you're re-deploying with fresh state but the same voters.

Set-based nullifier check: The core anti-replay. Once a nullifier is spent, it can never be spent again in this contract instance.

Domain tag (DOMAIN_VOTE): If this same contract later adds a delegateVote operation, the two operations use different nullifier domains, so voting doesn't consume delegation capacity and vice versa.


Choosing the Right Mechanism

Scenario Best Mechanism
Single owner performing sequential writes Counter nonce
Many users each doing one action Nullifier set
Same contract has multiple operations Domain separation
Cross-contract interaction Domain separation with contract address
High-throughput concurrent writes Nullifier set (no serialization)
Ordered state machine transitions Counter nonce
Sealed-bid auctions Nullifier (one bid per secret)
Governance voting Nullifier + domain separation

In practice, most non-trivial contracts use all three: nullifiers for the core user operations, domain separation for circuits within the same contract, and nonces where sequential ordering genuinely matters.


Common Mistakes

Not including context in nullifier derivation:

// Wrong — same nullifier works in any contract, any version
const nullifier = persistentCommit(secret);

// Right — scoped to this contract and operation
const nullifier = persistentCommit(
  secret.concat(ContractAddress.self().toBytes()).concat(DOMAIN_CLAIM)
);
Enter fullscreen mode Exit fullscreen mode

Checking nullifier after side effects:

// Wrong — race condition if check and record aren't atomic
sendShielded(recipient, coin, amount);
ledger.spentNullifiers.set(index, nullifier);  // too late

// Right — record first
ledger.spentNullifiers.set(index, nullifier);
sendShielded(recipient, coin, amount);
Enter fullscreen mode Exit fullscreen mode

Reusing domain tags across contract versions:

// Wrong — v2 of your contract accepts proofs generated for v1
const DOMAIN = bytes("my-contract:action");

// Right — version in the tag
const DOMAIN = bytes("my-contract-v2:action");
Enter fullscreen mode Exit fullscreen mode

Forgetting that nullifier storage grows forever:
The nullifier Merkle tree has a fixed size (MerkleTree<N, Bytes<32>>). If you expect more than 2^N uses over the contract's lifetime, plan accordingly. A 24-bit tree holds ~16 million nullifiers, which is enough for most use cases, but a governance system with many proposals might need to use a rolling window or external nullifier management.

Replay attacks are easy to prevent if you build them in from the start. The hard part is not forgetting them — they don't manifest in unit tests, they only matter when proofs get replayed in production. Design defensively.

Top comments (0)