Replay Attack Prevention in Compact: Nonces, Nullifiers, and Domain Separation
On Ethereum, replay protection is built into the protocol. Every account has a nonce; every transaction carries it; the node rejects duplicates. You don't think about it. Midnight doesn't work that way. Transactions arrive as zero-knowledge proofs. The network verifies the proof is valid — but it doesn't inherently know whether that exact proof has been submitted before. The public ledger sees the result of a valid proof, not the transaction itself. That means replay prevention is your job, not the protocol's, and you have to design it explicitly into every circuit that needs it. This tutorial covers the three mechanisms Compact gives you: counter-based nonces, set-based nullifiers derived from persistentCommit(secret, context), and domain separation tags. Each one addresses a different class of replay attack, and knowing which to reach for — and when to combine them — is one of the more consequential design decisions in Compact contract development. All three contracts in this article compile against the latest Compact compiler. Verified CI run: IamHarrie-Labs/compact-replay-prevention-guide It helps to be precise about what you're defending against. Operation replay: the same valid proof is submitted twice. A voter submits their vote, the transaction succeeds, an attacker captures the transaction and resubmits it. If the contract doesn't track which proofs have been consumed, the vote tallies twice. Cross-operation replay: a proof valid for one circuit is reused in a different circuit. A governance contract has both a castVote and a delegateVote circuit. Without domain separation, if both circuits hash the same inputs, a proof generated for voting might satisfy the delegation circuit — an attacker extracts real capability from a proof they didn't generate. Cross-contract replay: a proof from contract A is replayed against contract B. Two different election contracts with the same circuit structure — a voter's proof for one election might be valid against the other if the nullifier isn't scoped to the specific contract. The three mechanisms map to these threats: Threat Mechanism Operation replay Counter nonces or set-based nullifiers Cross-operation replay Domain separation tags Cross-contract replay Context-scoped nullifiers + domain separation A counter nonce assigns each participant a monotonically increasing number. Each operation must reference the current nonce; after a successful operation the nonce advances. Any replay of an old transaction carries an outdated nonce and fails immediately. This is the most transparent approach — nonces are public ledger state, so any participant can check their current value before submitting. It's the right choice when participants have known, stable identities and operations need strict ordering. pragma language_version >= 0.20; import CompactStandardLibrary; export ledger userNonces: Map, Uint>; export ledger operationCount: Counter; witness getUserKey(): Bytes; export circuit operate( userKey: Bytes, nonce: Uint, nextNonce: Uint ): [] { if (!userNonces.member(disclose(userKey))) { assert(disclose(nonce) == 0, "First operation must use nonce 0"); } else { assert( disclose(nonce) == userNonces.lookup(disclose(userKey)), "Invalid nonce — replay or out-of-order submission" ); } assert(disclose(nextNonce) > disclose(nonce), "nextNonce must exceed current nonce"); userNonces.insert(disclose(userKey), disclose(nextNonce)); operationCount.increment(1); } Three things in this contract that will bite you if you copy-paste common Solidity patterns directly. Map, Boolean> not Set<> — Compact has no Set type. Use Map where you'd reach for a set. This applies to nullifier storage too. member() before lookup() — calling lookup on a key that doesn't exist in the map panics at proof generation. Always check member first, or use insertDefault to pre-populate. Missing this check means first-time users will hit an opaque failure instead of a clean error message. The Uint arithmetic constraint — Compact's range types mean nonce + 1 on a Uint produces a Uint, which is wider than Uint and can't be assigned back to a ledger field. The fix is to pass both nonce (current, to verify) and nextNonce (computed off-chain, to store). The contract verifies strict ordering, and TypeScript handles the +1. This pattern recurs whenever you'd naturally write ledgerField = existingValue + constant. Who calls operate? The userKey is an exported circuit parameter. It's what the caller claims their identity is. In a real contract, you'd derive userKey from a private key via ownPublicKey() or a witness, not accept it freely. The circuit above accepts it as a public input for simplicity. Counter nonces have one significant limitation: they serialize operations. If two transactions with the same nonce are in-flight simultaneously, only one succeeds — the other must regenerate with the updated nonce. For single-owner or low-throughput contracts this is fine. For high-concurrency systems (airdrops, voting with thousands of simultaneous participants), nullifiers are the better choice. persistentCommit A nullifier is a one-time-use token derived from a private secret. Once consumed on-chain, any subsequent attempt to reuse the same nullifier is rejected. Unlike nonces, nullifiers don't require a known identity — the secret stays private; only the nullifier hash appears on the ledger. The bounty specifies nullifiers derived from persistentCommit(secret, context): persistentCommit(v: T, opening: Bytes): Bytes v = the private secret (what the commitment binds to) opening = the context/domain string (what scopes the nullifier) Using a fixed context string as the opening makes the nullifier deterministic (same inputs always produce the same output, which is what you need for replay detection) and scoped (changing the context string produces a different nullifier, letting the same secret participate in different campaigns without cross-campaign collision). pragma language_version >= 0.20; import CompactStandardLibrary; // Map, Boolean> is Compact's set pattern — no dedicated Set type exists export ledger spentNullifiers: Map, Boolean>; export ledger totalClaims: Counter; witness getSecret(): Bytes; export circuit claimReward(context: Bytes): [] { const secret = getSecret(); // Derive nullifier: persistentCommit(secret, context) // secret: private — binding, never on-chain // context: public — scopes this nullifier to a specific campaign const nullifier = persistentCommit>(secret, disclose(context)); assert( !spentNullifiers.member(disclose(nullifier)), "Nullifier already spent: reward already claimed" ); // Record nullifier BEFORE side effects — always spentNullifiers.insert(disclose(nullifier), disclose(true)); totalClaims.increment(1); } persistentCommit vs persistentHash for nullifiers persistentHash(v: T): Bytes is deterministic and produces a fixed hash. For a nullifier that just needs to be binding, it works. But it offers no built-in context scoping. persistentCommit(v: T, opening: Bytes): Bytes accepts an explicit opening value. When you use a domain string as the opening, you get a naturally scoped nullifier: persistentCommit(secret, pad(32, "airdrop:season-1:v1")) produces a different result from persistentCommit(secret, pad(32, "airdrop:season-2:v1")), even though the secret is identical. One secret, multiple campaigns, zero cross-campaign collision. With persistentHash you'd have to concatenate manually and hope your encoding is consistent. persistentCommit with a context argument makes the scoping explicit and type-safe. The context parameter in claimReward is publicly visible. If two different contracts both have a claimReward circuit and a participant uses the same secret in both, the nullifier from contract A (persistentCommit(secret, contextA)) differs from the nullifier in contract B (persistentCommit(secret, contextB)) as long as the context strings differ. The context acts as the scope boundary. In practice, include the contract address or a unique deployment ID in the context: // Off-chain: compute context with contract address for cross-contract safety const context = encodeContext("airdrop:season-1:v1", contractAddress); In Compact, a circuit is atomic — it either fully succeeds or fully reverts. But the logical ordering still matters for clarity and security audits: mark the nullifier as spent before executing side effects. If you do it the other way: // ❌ Side effect first — logically backwards totalClaims.increment(1); spentNullifiers.insert(disclose(nullifier), disclose(true)); // too late conceptually Auditors reading your contract will expect nullifier recording before state changes. More importantly, in future contract upgrades or more complex circuits where partial execution might become possible, the safe ordering protects you. spentNullifiers grows forever — one entry per claim, unbounded. For high-throughput or long-lived contracts, this is a real concern. Compact has no native pruning for maps; the current mitigation is designing campaigns with bounded participation (a fixed voter tree, a capped airdrop size) so the map growth is bounded by design. For contracts where unbounded growth is unavoidable, the off-chain client can query map size before executing and alert when it approaches the tree capacity. Domain separation solves cross-operation replay. The threat: a contract has two circuits that hash similar inputs. Without distinct tags, a proof valid for circuit A might hash to the same value as circuit B, letting an attacker reuse one proof for a different operation. The fix is a unique prefix on every hash computation: persistentHash>>([ pad(32, "contract-name:operation:version"), ...data ]) Even if two circuits receive identical data inputs, different domain tags produce completely different outputs. The circuits become cryptographically isolated. pragma language_version >= 0.20; import CompactStandardLibrary; export ledger voteNullifiers: Map, Boolean>; export ledger delegateNullifiers: Map, Boolean>; export ledger totalVotes: Counter; export ledger totalDelegations: Counter; witness getVoterSecret(): Bytes; circuit voteNullifier(secret: Bytes, proposalId: Bytes): Bytes { return persistentHash>>([ pad(32, "gov:vote:v1"), // ← vote-specific domain tag secret, proposalId ]); } circuit delegateNullifier(secret: Bytes, proposalId: Bytes): Bytes { return persistentHash>>([ pad(32, "gov:delegate:v1"), // ← delegate-specific domain tag — different hash space secret, proposalId ]); } export circuit castVote(proposalId: Bytes): [] { const secret = getVoterSecret(); const nullifier = voteNullifier(secret, disclose(proposalId)); assert(!voteNullifiers.member(disclose(nullifier)), "Vote already cast for this proposal"); voteNullifiers.insert(disclose(nullifier), disclose(true)); totalVotes.increment(1); } export circuit delegateVote(proposalId: Bytes): [] { const secret = getVoterSecret(); const nullifier = delegateNullifier(secret, disclose(proposalId)); assert(!delegateNullifiers.member(disclose(nullifier)), "Already delegated for this proposal"); delegateNullifiers.insert(disclose(nullifier), disclose(true)); totalDelegations.increment(1); } The same voter (same secret) can both vote and delegate on the same proposal. The voteNullifier and delegateNullifier circuits produce different hashes for identical inputs because the tags differ. No cross-operation collision is possible. Good tags follow a consistent pattern: {project}:{contract}:{operation}:v{version} Examples: "gov:proposal:nullifier:v1" — proposal submission in a governance contract "token:transfer:nullifier:v1" — transfer in a token contract "airdrop:claim:nullifier:v1" — airdrop claim The version suffix matters for upgrades. If you change the contract's logic and redeploy, existing nullifiers from the old version should remain valid or explicitly invalid — a version bump in the tag ensures the two contract generations don't share a hash space. Always use pad(32, tag) to get a fixed 32-byte prefix. pad left-pads the string with zeros. Without it, variable-length tags create ambiguity: "a" || "bc" and "ab" || "c" would produce the same byte sequence and could collide. Midnight's own Bulletin Board contract uses this exact approach: export circuit publicKey(sk: Bytes, sequence: Bytes): Bytes { return persistentHash>>([ pad(32, "bboard:pk:"), sequence, sk ]); } The "bboard:pk:" prefix ensures public keys derived here can't be confused with hashes from any other part of the system — including other contracts that might use the same sk. The strongest contracts layer all three mechanisms. Here's a minimal DAO governance contract that uses nonces for proposal submission ordering, domain-separated nullifiers for anonymous voting, and explicit tag versioning: pragma language_version >= 0.20; import CompactStandardLibrary; // Phase state export ledger proposalOpen: Boolean; export ledger proposalId: Bytes; // Counter nonce: proposal submission is identity-bound and sequentially ordered export ledger submitterNonces: Map, Uint>; export ledger proposalCount: Counter; // Nullifier set: voting is anonymous — nullifiers prevent double-vote export ledger voteNullifiers: Map, Boolean>; export ledger yesVotes: Counter; export ledger noVotes: Counter; witness getVoterSecret(): Bytes; // Domain-separated helper — "dao:vote:v1" isolates vote hashes from all other ops circuit computeVoteNullifier( secret: Bytes, proposal: Bytes ): Bytes { return persistentHash>>([ pad(32, "dao:vote:v1"), secret, proposal ]); } // Submit a proposal — identity-bound, nonce-protected export circuit submitProposal( submitterKey: Bytes, nonce: Uint, nextNonce: Uint, content: Bytes ): [] { assert(!proposalOpen, "Proposal already open"); // Counter nonce: verify and advance if (!submitterNonces.member(disclose(submitterKey))) { assert(disclose(nonce) == 0, "First submission must use nonce 0"); } else { assert( disclose(nonce) == submitterNonces.lookup(disclose(submitterKey)), "Invalid nonce" ); } assert(disclose(nextNonce) > disclose(nonce), "nextNonce must increase"); submitterNonces.insert(disclose(submitterKey), disclose(nextNonce)); proposalId = disclose(content); proposalOpen = disclose(true); proposalCount.increment(1); } // Cast a vote — anonymous, nullifier-protected, domain-separated export circuit castVote(vote: Uint): [] { assert(proposalOpen, "No open proposal"); const secret = getVoterSecret(); const nullifier = computeVoteNullifier(secret, proposalId); // Replay prevention: nullifier check before side effects assert(!voteNullifiers.member(disclose(nullifier)), "Already voted"); voteNullifiers.insert(disclose(nullifier), disclose(true)); if (disclose(vote) == 1) { yesVotes.increment(1); } else { noVotes.increment(1); } } export circuit closeProposal(): [] { assert(proposalOpen, "No open proposal"); proposalOpen = disclose(false); } This contract uses nonces for the submitter (identity matters, ordering matters) and nullifiers for voters (identity is private, concurrency is expected). The "dao:vote:v1" tag ensures vote hashes can never collide with any other operation in a larger system, and the version suffix leaves room for future upgrades. Scenario Use Registered users, strict sequential ordering needed Counter nonces Anonymous participation, high concurrency Set-based nullifiers Multiple distinct operations in one contract Domain separation Multi-contract system sharing user secrets Context-scoped nullifiers + domain separation Time-bound campaigns (voting, airdrops) Nullifiers with campaign ID in context Contract upgrade path needed Versioned domain tags (v1, v2) Counter nonces and nullifiers both prevent operation replay, but they trade off differently: nonces are sequential and identity-revealing; nullifiers allow concurrency and preserve privacy. Domain separation is not an alternative to either — it's a required complement whenever a contract has more than one circuit that hashes related data. The strongest contracts layer all three: domain-separated nullifiers for the core replay check, plus counter nonces for any ordered operations that require strict sequencing. Set — doesn't exist in Compact // ❌ Compile error — Compact has no Set type export ledger spentNullifiers: Set>; // ✅ Correct — use Map as a set export ledger spentNullifiers: Map, Boolean>; Map.lookup() without Map.member() check — panics on missing key // ❌ Panics at proof generation if userKey has never been inserted const current = userNonces.lookup(disclose(userKey)); // ✅ Always check member() first if (userNonces.member(disclose(userKey))) { const current = userNonces.lookup(disclose(userKey)); assert(disclose(nonce) == current, "Invalid nonce"); } else { assert(disclose(nonce) == 0, "First use must start at nonce 0"); } Uint arithmetic in-circuit produces a wider range type // ❌ Type error: Uint + 1 produces Uint, not Uint userNonces.insert(disclose(userKey), disclose(current + 1)); expected right-hand side to have type Uint but received Uint // ✅ Pass nextNonce from off-chain TypeScript, verify > current in-circuit userNonces.insert(disclose(userKey), disclose(nextNonce)); disclose() on exported parameters in comparisons // ❌ Compiler flags the comparison — may produce disclosure error assert(nonce == userNonces.lookup(disclose(userKey)), "Invalid nonce"); // ✅ Exported parameters need disclose() before ledger comparisons assert(disclose(nonce) == userNonces.lookup(disclose(userKey)), "Invalid nonce"); Compact compiler error: potential witness-value disclosure must be declared but is not — performing this ledger operation might disclose the boolean value of the result of a comparison involving the witness value // ❌ Same secret produces same nullifier in every contract — cross-contract replay risk const nullifier = persistentCommit>(secret, pad(32, "no-scope")); // ✅ Include contract-specific context in the opening const nullifier = persistentCommit>(secret, disclose(campaignContext)); // where campaignContext includes contract address or unique deployment ID // ❌ No version — upgrading the contract logic shares the hash space with v1 pad(32, "gov:vote") // ✅ Versioned — v2 deployments have isolated nullifier sets from v1 pad(32, "gov:vote:v1") pad(32, "gov:vote:v2") // for the upgraded contract // ❌ Side effects before nullifier record — logically backwards totalClaims.increment(1); spentNullifiers.insert(disclose(nullifier), disclose(true)); // ✅ Record nullifier first, then side effects spentNullifiers.insert(disclose(nullifier), disclose(true)); totalClaims.increment(1); Compact circuits are atomic, so this doesn't affect transaction safety. But it protects you during audits and contract evolution, and is the defensively correct pattern. All three contracts in this article — counter-nonce.compact, nullifier.compact, and domain-separation.compact — compile against the latest Compact compiler. The full source and CI run are at: https://github.com/IamHarrie-Labs/compact-replay-prevention-guide/actions/runs/25690416194 Compact Language Reference Standard Library: persistentHash, persistentCommit Bulletin Board Tutorial — canonical domain separation example ("bboard:pk:") Midnight Developer Forum
