DEV Community

Cover image for Replay Attack Prevention in Compact: Nonces, Nullifiers, and Domain Separation
Harrie
Harrie

Posted on

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


Three kinds of replay

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

Mechanism 1: Counter-based nonces

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<Bytes<32>, Uint<64>>;
export ledger operationCount: Counter;

witness getUserKey(): Bytes<32>;

export circuit operate(
    userKey: Bytes<32>,
    nonce: Uint<64>,
    nextNonce: Uint<64>
): [] {
    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);
}
Enter fullscreen mode Exit fullscreen mode

Three things in this contract that will bite you if you copy-paste common Solidity patterns directly.

Map<Bytes<32>, Boolean> not Set<> — Compact has no Set type. Use Map<K, Boolean> 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<64> arithmetic constraint — Compact's range types mean nonce + 1 on a Uint<64> produces a Uint<1..2^64>, which is wider than Uint<64> 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.

When nonces fall short

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.


Mechanism 2: Set-based nullifiers with 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<T>(v: T, opening: Bytes<32>): Bytes<32>
Enter fullscreen mode Exit fullscreen mode
  • 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<Bytes<32>, Boolean> is Compact's set pattern — no dedicated Set type exists
export ledger spentNullifiers: Map<Bytes<32>, Boolean>;
export ledger totalClaims: Counter;

witness getSecret(): Bytes<32>;

export circuit claimReward(context: Bytes<32>): [] {
    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<Bytes<32>>(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);
}
Enter fullscreen mode Exit fullscreen mode

persistentCommit vs persistentHash for nullifiers

persistentHash<T>(v: T): Bytes<32> 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<T>(v: T, opening: Bytes<32>): Bytes<32> 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.

Context scoping prevents cross-contract replay

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);
Enter fullscreen mode Exit fullscreen mode

Record nullifier before side effects

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
Enter fullscreen mode Exit fullscreen mode

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.

Storage growth

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.


Mechanism 3: Domain separation tags

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<Vector<n, Bytes<32>>>([
    pad(32, "contract-name:operation:version"),
    ...data
])
Enter fullscreen mode Exit fullscreen mode

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<Bytes<32>, Boolean>;
export ledger delegateNullifiers: Map<Bytes<32>, Boolean>;
export ledger totalVotes: Counter;
export ledger totalDelegations: Counter;

witness getVoterSecret(): Bytes<32>;

circuit voteNullifier(secret: Bytes<32>, proposalId: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>([
        pad(32, "gov:vote:v1"),       // ← vote-specific domain tag
        secret,
        proposalId
    ]);
}

circuit delegateNullifier(secret: Bytes<32>, proposalId: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>([
        pad(32, "gov:delegate:v1"),   // ← delegate-specific domain tag — different hash space
        secret,
        proposalId
    ]);
}

export circuit castVote(proposalId: Bytes<32>): [] {
    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<32>): [] {
    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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Domain tag design

Good tags follow a consistent pattern:

{project}:{contract}:{operation}:v{version}
Enter fullscreen mode Exit fullscreen mode

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.

The official Midnight pattern

Midnight's own Bulletin Board contract uses this exact approach:

export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>([
        pad(32, "bboard:pk:"),
        sequence,
        sk
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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.


Combining all three: a governance contract

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<32>;

// Counter nonce: proposal submission is identity-bound and sequentially ordered
export ledger submitterNonces: Map<Bytes<32>, Uint<64>>;
export ledger proposalCount: Counter;

// Nullifier set: voting is anonymous — nullifiers prevent double-vote
export ledger voteNullifiers: Map<Bytes<32>, Boolean>;
export ledger yesVotes: Counter;
export ledger noVotes: Counter;

witness getVoterSecret(): Bytes<32>;

// Domain-separated helper — "dao:vote:v1" isolates vote hashes from all other ops
circuit computeVoteNullifier(
    secret: Bytes<32>,
    proposal: Bytes<32>
): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>([
        pad(32, "dao:vote:v1"),
        secret,
        proposal
    ]);
}

// Submit a proposal — identity-bound, nonce-protected
export circuit submitProposal(
    submitterKey: Bytes<32>,
    nonce: Uint<64>,
    nextNonce: Uint<64>,
    content: Bytes<32>
): [] {
    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<8>): [] {
    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);
}
Enter fullscreen mode Exit fullscreen mode

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.


When to use which

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.


Common pitfalls

1. Using Set<T> — doesn't exist in Compact

// ❌ Compile error — Compact has no Set type
export ledger spentNullifiers: Set<Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode
// ✅ Correct — use Map<K, Boolean> as a set
export ledger spentNullifiers: Map<Bytes<32>, Boolean>;
Enter fullscreen mode Exit fullscreen mode

2. 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));
Enter fullscreen mode Exit fullscreen mode
// ✅ 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");
}
Enter fullscreen mode Exit fullscreen mode

3. Uint<64> arithmetic in-circuit produces a wider range type

// ❌ Type error: Uint<64> + 1 produces Uint<1..2^64>, not Uint<64>
userNonces.insert(disclose(userKey), disclose(current + 1));
Enter fullscreen mode Exit fullscreen mode
expected right-hand side to have type Uint<64>
but received Uint<1..18446744073709551616>
Enter fullscreen mode Exit fullscreen mode
// ✅ Pass nextNonce from off-chain TypeScript, verify > current in-circuit
userNonces.insert(disclose(userKey), disclose(nextNonce));
Enter fullscreen mode Exit fullscreen mode

4. Missing disclose() on exported parameters in comparisons

// ❌ Compiler flags the comparison — may produce disclosure error
assert(nonce == userNonces.lookup(disclose(userKey)), "Invalid nonce");
Enter fullscreen mode Exit fullscreen mode
// ✅ Exported parameters need disclose() before ledger comparisons
assert(disclose(nonce) == userNonces.lookup(disclose(userKey)), "Invalid nonce");
Enter fullscreen mode Exit fullscreen mode

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

5. Nullifiers without context — same nullifier across deployments

// ❌ Same secret produces same nullifier in every contract — cross-contract replay risk
const nullifier = persistentCommit<Bytes<32>>(secret, pad(32, "no-scope"));
Enter fullscreen mode Exit fullscreen mode
// ✅ Include contract-specific context in the opening
const nullifier = persistentCommit<Bytes<32>>(secret, disclose(campaignContext));
// where campaignContext includes contract address or unique deployment ID
Enter fullscreen mode Exit fullscreen mode

6. Domain tags without version — upgrade breaks nullifier isolation

// ❌ No version — upgrading the contract logic shares the hash space with v1
pad(32, "gov:vote")
Enter fullscreen mode Exit fullscreen mode
// ✅ Versioned — v2 deployments have isolated nullifier sets from v1
pad(32, "gov:vote:v1")
pad(32, "gov:vote:v2")  // for the upgraded contract
Enter fullscreen mode Exit fullscreen mode

7. Recording nullifier after side effects

// ❌ Side effects before nullifier record — logically backwards
totalClaims.increment(1);
spentNullifiers.insert(disclose(nullifier), disclose(true));
Enter fullscreen mode Exit fullscreen mode
// ✅ Record nullifier first, then side effects
spentNullifiers.insert(disclose(nullifier), disclose(true));
totalClaims.increment(1);
Enter fullscreen mode Exit fullscreen mode

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.


Compiler-verified source

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


Resources

Top comments (0)