惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

SecWiki News
SecWiki News
量子位
The Cloudflare Blog
美团技术团队
T
The Exploit Database - CXSecurity.com
博客园 - 【当耐特】
Spread Privacy
Spread Privacy
P
Proofpoint News Feed
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园 - 三生石上(FineUI控件)
T
Tor Project blog
博客园 - 司徒正美
宝玉的分享
宝玉的分享
T
Threatpost
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
S
Secure Thoughts
T
Threat Research - Cisco Blogs
Hacker News: Ask HN
Hacker News: Ask HN
Jina AI
Jina AI
博客园 - 聂微东
A
Arctic Wolf
I
Intezer
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Know Your Adversary
Know Your Adversary
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
爱范儿
爱范儿
Hugging Face - Blog
Hugging Face - Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
小众软件
小众软件
T
Tailwind CSS Blog
The Hacker News
The Hacker News
L
LINUX DO - 最新话题
Hacker News - Newest:
Hacker News - Newest: "LLM"
WordPress大学
WordPress大学
S
SegmentFault 最新的问题
TaoSecurity Blog
TaoSecurity Blog
Project Zero
Project Zero
博客园 - 叶小钗
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Cloudbric
Cloudbric
雷峰网
雷峰网
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
大猫的无限游戏
大猫的无限游戏
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Troy Hunt's Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
V2EX - 技术
V2EX - 技术
The GitHub Blog
The GitHub Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
P
Privacy & Cybersecurity Law Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Replay Attack Prevention in Compact: Nonces, Nullifiers, and Domain Separation
Harrie · 2026-05-12 · via DEV Community

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