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:
- Alice calls
claimAirdrop()— generates a valid ZK proof, submits transaction, receives 100 MNT - Alice submits the same proof again — it's still valid, the circuit logic is satisfied, she receives another 100 MNT
- 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);
}
}
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
}
}
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];
}
}
Why This Works
The proof of work here:
- The prover generates
nullifier = persistentCommit(claimSecret, "airdrop-v1") - The circuit verifies this derivation in-circuit (the
assert nullifier == persistentCommit(...)line) - The nullifier is added to a public on-chain set
- Future proofs with the same
claimSecretwill derive the samenullifier, 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);
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);
}
}
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
)];
}
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];
}
}
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)
);
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);
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");
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)