Research Notes: What Zero-Knowledge Proofs Enable on CKB

Gm! I am opening this topic to share my findings as I do some zk research on CKB and explore what is possible and also contribute to existing projects. This is Note 01 in my ongoing research.

What I Think Is Possible and Why the Architecture Makes It Tractable

NOTE: These are my personal research notes documenting what I am learning and finding as I explore zero-knowledge proofs on CKB. Discussion and feedback welcome.

Where this starts

I have been spending time with CKB, reading the specs, building with Molecule, studying the cell model. At some point I started asking a different question: not just what CKB does, but what it could do if you layered zero-knowledge proofs on top of it.

This note is my attempt to think through that question honestly. It is not a tutorial and not a product announcement. It is an exploration of whether the architecture supports what I think it supports, and what becomes possible if it does.

How CKB is designed

To understand why ZK fits, you need to understand how CKB works at a fundamental level. Three properties matter most here.

Everything is a cell.

CKB does not have accounts. It has cells: simple containers with a capacity (CKB tokens locked inside), a lock script (who can spend it), and a data field (any bytes you want to store). Your balance is not a number stored anywhere. It is the sum of capacity across all live cells your key controls.

Transactions consume cells and produce new ones. There is no “update” operation. Old cells die, new cells are born.

transaction
  inputs   = cells being consumed
  outputs  = new cells being created

This explicit consume-and-produce model means state is always visible, portable, and atomic. Every state transition is a transaction. Every transaction is verifiable.

Scripts only verify, never compute.

On Ethereum, smart contracts run computation. They update state, call other contracts, emit events. On CKB, scripts do one thing: they verify. A lock script verifies the spender has the right key. A type script verifies a state transition is valid. Scripts return success or failure. Nothing else.

This is a fundamental difference. CKB was not designed for on-chain computation. It was designed for on-chain verification.

CKB-VM runs RISC-V.

Most blockchains run contracts on custom VMs with fixed instruction sets. Adding new operations requires new opcodes, which requires a hard fork.

CKB-VM runs RISC-V, a real hardware instruction set. Any code that compiles to RISC-V can run as a CKB script. No special opcodes needed. No protocol changes needed. The chain does not care what your script does internally; it runs it and charges cycles.

No cryptographic operations are hardcoded. The default signature verification and hash functions ship as deployable scripts, not protocol primitives. New cryptographic primitives are deployed the same way any code is deployed, as a cell. This has an interesting consequence for quantum resistance. If a quantum-safe signature scheme emerges, CKB can adopt it by deploying a new script, no hard fork required. But that is a story for another note.

Why this maps naturally onto ZK

Zero-knowledge proofs have a prover and a verifier.

prover    runs expensive computation off-chain
          produces a short cryptographic proof

verifier  checks the proof cheaply
          never re-runs the computation
          learns the result but not the private inputs

Now look at CKB’s design again:

CKB scripts    verify state transitions
               never run the computation themselves
               return success or failure

ZK verifiers   verify proofs of correct computation
               never re-run the computation themselves
               return valid or invalid

They are structurally the same thing. CKB was built as a verification layer. ZK proofs are things that need to be verified. The fit does not feel like a coincidence.

The cell model reinforces this. ZK proofs often need to commit to state: “I am proving something about the current state of this data.” On CKB, state is explicit. It lives in cells. A ZK state transition looks like this:

input cell    = old state
output cell   = new state
witness       = ZK proof

type script:
  read old state from input cell
  read new state from output cell
  verify the ZK proof
  if proof valid -> accept state transition
  if proof invalid -> reject transaction

Old state, new state, proof. Three things. All handled by existing CKB primitives. Nothing special required from the protocol.

And because CKB-VM runs RISC-V, you can implement any ZK verifier (Groth16, PLONK, STARKs, anything) as a native script. You compile your verifier to a RISC-V binary, deploy it as a cell, and type scripts call it. The chain runs it and charges cycles.

What the cycle cost model means

CKB charges cycles for every RISC-V instruction executed. There are no hidden costs, no gas estimation surprises, no special pricing for specific operations.

This matters for ZK because ZK verifiers are computationally intensive. A Groth16 verifier involves elliptic curve pairings, among the most expensive operations in applied cryptography. On Ethereum, the cost of running a verifier depends on whether a precompile exists for your proof system, how that precompile is priced, and what gas limit the block allows. If no precompile exists for your proof system, you pay full EVM gas for every operation.

On CKB, the cost is whatever your verifier costs to execute in RISC-V cycles. Old proof system, new proof system, experimental proof system, all use the same pricing model, the same deployment process, the same rules.

I built a Groth16/BN254 verifier to test this concretely. These are the numbers from the production call path, with the verifying key decoded from a cell_dep, the proof read from the witness, and the full pairing check running on riscv64imac CKB-VM:

cycles per verification   ~102 million
CKB block cycle limit     3.5 billion
block usage per verify    ~2.9%

2.9% of a block per verification. That is practically usable. It means a deployable, measurable Groth16 verifier exists on CKB today, and the same approach generalizes to any other proof system that compiles to RISC-V.

What becomes possible

With this foundation, a few categories of application start to look natural on CKB. Most of them are technically possible on other chains. The difference is that the cost and ergonomics on EVM chains depend on whether a precompile happens to exist for your proof system, and the state layout has to be squeezed into a key-value abstraction. On CKB, the verifier is just code deployed like anything else, and the state is just bytes in cells.

  • Private state transitions.

    A type script can verify a ZK proof without knowing the private inputs that generated it. The proof goes in the witness. Public commitments such as a nullifier, a new state root, or an output commitment go into the output cell’s data field. The chain sees that a valid proof was submitted and that the new commitments are well-formed. It does not see what was proved.

  • Membership proofs without identity disclosure.

    Prove you are in a set without revealing which member you are. The eligible set is committed to publicly as a Merkle root, stored as bytes in a cell’s data field. The proof shows you know a path from a leaf to that root. A type script on the cell verifies the inclusion proof and accepts the spend if valid. Nullifier sets that need to grow get their own cell, updated by the same script. No registry contract, no precompile, just cells and a verifier script.

    This is directly relevant to governance, where the use case is proving voting eligibility without revealing voter identity.

  • Verifiable computation with private inputs.

    Run a computation off-chain. Generate a proof that the computation was done correctly. Submit the proof and the public outputs on-chain. The chain verifies the proof. Anyone can confirm the result is correct without re-running the computation or seeing the inputs. The honest constraint is that the computation has to be expressible as a circuit your prover supports. Within that constraint, the on-chain story stays the same.

  • Proof-aggregated batched updates.

    Aggregate many state transitions off-chain. Generate a single proof that all of them are valid. Submit one transaction with one input cell carrying the old aggregate state, one output cell carrying the new aggregate state, and one witness carrying the aggregation proof. The cell model handles the verification side cleanly because the inputs and outputs already represent state before and after.

    A full rollup is more than this. It also needs data availability and an exit mechanism that does not depend on operator cooperation. Those are separate problems that a verifier alone does not solve. But the proof-checking layer that every rollup-style design relies on slots into CKB without anything custom from the protocol.

  • A concrete primitive: the verification slot.

    While building the Groth16 verifier I ended up with a small composability primitive worth naming. A cell sits on chain whose type script is the verifier, bound to one specific verifying key by its type-script args. The verifier permits the cell to be created without a proof, then requires a valid proof to spend it. The cell becomes an open verification slot, bound to exactly one computation. Anyone holding a valid proof for that computation can spend it.

    This kind of “stateful slot anyone can satisfy by proving X” composes naturally on CKB because cells are first-class objects with their own type script and their own data. It is harder to express cleanly on chains where verification is a function call against a fixed contract.

My findings

The most concrete current use of ZK on CKB is the voting PoC for the Nervos DAO treasury, which uses the SP1 zkVM. While reading around it, two questions stood out to me. I treated each as an attack scenario and traced through the guest program to see where the attack dies.

Question 1. Can a prover selectively omit unfavorable votes?

The setup: the prover wants to leave NO votes out of the tally so a proposal passes that should fail. There are four obvious avenues.

Attack avenue What stops it
Tamper with a block body to delete a vote transaction The header commits to transactions_root. The Merkle root recomputed from the modified body no longer matches the header.
Skip entire blocks to omit a range of votes Each block’s parent_hash is checked against the previous block’s hash. A gap breaks the chain.
Bend the filter so honest NO votes look invalid The filter is a pure function of the cell’s own code_hash, hash_type, and args. A real vote cell has the values it has.
Lie about voting duration to feed fewer blocks Duration is read from the proposal cell’s own data, anchored to the real chain via the start block hash. The guest demands exactly duration + 1 blocks.

The check that does most of the work lives in verify_block_integrity:

let prev_hash = header_hash(prev_block.header());
let parent_hash = byte32_to_arr(current_block.header().raw().parent_hash());
if prev_hash != parent_hash {
    return Err(Error::ParentHashMismatch { block_index: i });
}

These are not ad-hoc patches. They follow from one property: a block header hash commits to its body and to its parent. Break the body and the root mismatches. Break the chain and the parent hash mismatches. The prover has no flex.

Question 2. Can a voter double-count a DAO deposit across withdrawals?

The setup: Alice deposits 1000 CKB to address₁, votes YES, withdraws, redeposits 1000 CKB to address₂, votes YES again. Goal: 2000 CKB of weight from 1000 CKB of stake.

The guest program tracks two maps. dao_outpoint_to_voter records which deposit belongs to which voter. vote_map records which voter chose what. When Alice withdraws her first deposit, that deposit shows up as a transaction input. The guest treats every input as a potential spend event:

for input in raw.inputs().iter() {
    let op_bytes: [u8; 36] = input.previous_output().as_slice().try_into().expect(...);
    if let Some(voter_lock_hash) = dao_outpoint_to_voter.remove(&op_bytes) {
        vote_map.remove(&voter_lock_hash);
    }
}

The moment Alice’s old deposit appears as an input, her first vote is removed from the tally. By the time her second vote registers from address₂, she is a fresh voter with 1000 CKB of stake. The final count is one YES vote, not two.

Variation Outcome
Withdraws after the voting window closes Vote stayed valid. The window is over.
Votes with someone else’s deposit Rejected on chain by the vote type script (the lock must match the DAO deposit owner).
Two voters somehow share a deposit Same on-chain check rejects it. The guest also dedups at the outpoint level.

The pattern these answers share

Both attacks fail for the same fundamental reason. The design forces the prover through cryptographic checkpoints whose values cannot be lied about, and uses each checkpoint to enforce an invariant. For Question 1, every block must hash to a value that chains to its neighbor and Merkle-roots to its header; the prover cannot pick what is in a block. For Question 2, every spend in the range is processed and cross-referenced against active votes; the prover cannot quietly forget a withdrawal.

What ties them together is the immutability of historical block data. The prover does not get to summarize, edit, or omit. They are forced to replay the chain honestly because every step they take has a cryptographic anchor that the verifier checks independently.

What I am taking from this

The architectural fit I wrote about earlier is what makes this kind of design possible in the first place. CKB-VM running RISC-V meant one could deploy a real SP1 verifier without protocol changes. The cell model meant the proposal cell, the vote cells, and the proof check compose without any registry contract. The result is a soundness story that holds up to scrutiny.

What stays genuinely open in this design space is privacy, not soundness. The proof’s intermediate state links voter identities to vote choices, and a public observer watching DAO deposits and vote cells over a voting window can correlate the two. Whether ZK can layer anonymity on top of this design without breaking what is already working is a different question than the one I started with, and one worth more thought before I claim anything about it.


References:

8 Likes

Dedup without nullifiers: what this design borrows from CKB

This is Note 02 in my ongoing research.

Continuing from Note 1

We closed Note 1 on an open question. The soundness story of this voting design holds up: votes can’t be omitted, deposits can’t be double-counted. Privacy is a separate question, and the answer there is different. A public observer watching DAO deposits and vote cells over a voting window can correlate the two and read off who voted what. I left it there because layering anonymity onto a working design is a different question than the one I started with.

This note is not a privacy proposal. Before asking “how would we add privacy?”, I wanted to understand what dedup primitive this design uses instead of nullifiers, and why it didn’t need to manufacture an anonymous one. The answer turns out to be a useful design lesson in its own right. The privacy question itself, how to layer anonymity onto a working design, is for a later note.

Nullifiers: what they are, what they do

A nullifier is a one-time tag that says “this secret has been used.” It’s the workhorse primitive behind anonymous double-vote-resistant voting systems like Semaphore and MACI, borrowed from how Zcash prevents double-spending.

(For a working code example of nullifier derivation and verification, I built one here. The rest of this post assumes you know the basic mechanism.)

The recipe is usually:

nullifier = hash(secret_key, context)

where context scopes the action: a proposal ID, an epoch, whatever identifies “the thing you’re voting on.” From this construction you get four properties:

  • Deterministic: same secret + same context produces the same nullifier
  • Unique: different secrets produce different nullifiers
  • Unlinkable: the nullifier reveals nothing about the secret or the voter
  • Unforgeable: only the secret-holder can produce the right nullifier

In a voting flow, the voter’s secret commitment sits in a Merkle tree of eligible voters. When they cast a vote, they publish the nullifier alongside a zk proof of two things:

  • Membership: they know a secret whose commitment is in the tree
  • Honest derivation: the nullifier was computed from that secret using the recipe above, not invented

The verifier maintains a seen-set; if the nullifier is already there, the second vote is rejected.

The reason this primitive is everywhere in zk voting is what it manufactures: unique-but-anonymous identity. The chain can tell “whoever this is, they’ve voted” without knowing who they are.


But this design skips them

So I went looking for the nullifier scheme in this design. Specifically: a Merkle tree of voter commitments, a nullifier field on each vote, a seen_tags set in the proof’s public values, a circuit deriving a one-time tag from a voter secret. I mean the usual furniture!

None of it is there.

The proposal cell’s args hold a Type ID and an SP1 verifying key hash. The vote cell’s data is { vote, amount, dao_index }. The proof’s PublicValues are { proposal, start_block_hash, end_block_hash, proposal_script, passed, yes_vote, no_vote }. There is no Poseidon hash anywhere in the SP1 guest, no Merkle membership proof, no commitment to a set of secrets.

The guest does build Merkle trees, but for a different job: verifying each block’s transactions_root field against the actual transactions in that block. Same data structure, different purpose. Nothing about voter eligibility, nothing about hiding identity.

And yet it is double-vote resistant. Note 1 verified that.

So if there’s no nullifier doing the dedup, what is? That’s the rest of this note.


What CKB does instead

The dedup mechanism is three lines of Rust in the SP1 guest.

// voter lock hash -> (direction: 0=NO / 1=YES, amount in shannon)
let mut vote_map: BTreeMap<[u8; 32], (u8, u64)> = BTreeMap::new();

A BTreeMap. No nullifier set, no Merkle tree, no Poseidon. The key is a 32-byte voter identity. The value is what they voted and how much stake they hold.

When the guest finds a vote cell during the block walk, the voter identity is computed as:

let voter_lock_hash = blake2b_256(output.lock().as_slice());

The blake2b hash of the cell’s lock script. The lock script is the public expression of “who can spend this cell,” typically a signature scheme bound to a specific public key. Two cells with the same lock script are owned by the same private-key holder.

Then the dedup itself:

vote_map.insert(voter_lock_hash, (direction, amount));

A standard BTreeMap::insert. If a key already exists, the new value replaces it. That single semantic is the entire dedup primitive: a second vote from the same voter_lock_hash silently overwrites the first. The “vote retraction” feature in the spec is a free side-effect of this, not a separate mechanism.

What stops a voter from forging this identity? The on-chain vote type script verifies that the vote cell’s lock matches the DAO deposit’s lock. By the time the guest sees a vote cell in a block, CKB consensus has already proven that the voter controls the keys to that lock. The guest never needs to verify identity. The chain did.


Why it works: UTXO as the dedup primitive

Nullifiers manufacture unique-but-anonymous identity. The chain learns that someone voted, without learning who.

CKB’s UTXO model already provides unique-but-public identity. The chain learns that this specific lock script voted, and it knows whose key controls that lock script.

That asymmetry is the whole story. If your dedup primitive needs anonymity, you have to manufacture the identity inside the circuit, because the chain can’t tell you who’s who without learning who they are. Nullifiers do exactly that. If your dedup primitive does not need anonymity, you can skip the manufacturing step entirely and read identity straight off the substrate.

CKB hands you that for free. A lock script is signature-gated: only the holder of the corresponding key can authorize a transaction that creates a cell with that lock. Consensus enforces it every time a block is accepted. Two cells with the same lock script provably came from the same key holder. That is the same uniqueness guarantee a nullifier provides, except it comes from consensus instead of cryptography, and it shows up as a 32-byte hash you can use as a BTreeMap key.

You don’t add a primitive. You borrow what’s already there.


The trade-off

The way I have come to see it, the choice between the two primitives reduces to a single question: is anonymity in the threat model? If it is, the design has to manufacture identity in zero-knowledge (nullifiers). If it isn’t, the design can read identity off the chain directly (lock-script hashes here).

Property Nullifiers manufacture UTXO already provides
Voter uniqueness yes yes
Anonymity yes no
Unforgeability via secret-knowledge via signature gating
Circuit cost high low
Works on any chain UTXO-style chains

Nullifiers are the right primitive when “observers learning who voted” is itself a failure case. The canonical examples I have come across are whistleblower votes, anti-collusion DAO governance (the use case MACI was built for), corporate board votes where retaliation is real, and identity systems where membership must be provable without revealing which member. The cost is real: an extra primitive, a larger circuit, a Merkle tree to maintain, an audit surface that grows with all of it.

Where public attribution is acceptable, or part of the design intent, none of that machinery is needed. For stake-weighted public governance, the vote cell has to publish dao_index (pointing at the voter’s DAO deposit) and amount (the stake weight) so the guest can verify the tally. Both fields identify the voter. The dao_index points at a publicly owned cell. The amount correlates against publicly indexable stake balances. Adding a nullifier on the vote cell can’t hide what the protocol requires the cell to publish in the first place. That is the trade this design made.


The extra defense: per-DAO-deposit dedup

The lock-script dedup catches identity reuse. It doesn’t, on its own, catch “withdraw the deposit, redeposit at a fresh address, vote again.” Different lock script, different voter_lock_hash, two entries in vote_map. Same 1,000 CKB of stake. Double weight.

Note 1 covered this attack in Question 2. Here is the code-level mechanism that defeats it.

The guest maintains a second map alongside vote_map:

// DAO deposit outpoint (36 bytes) -> voter lock hash
let mut dao_outpoint_to_voter: BTreeMap<[u8; 36], [u8; 32]> = BTreeMap::new();

When a vote cell is recorded, every DAO deposit outpoint it references is also recorded, mapped to that voter’s lock hash. Then every transaction in the voting window gets scanned:

for input in raw.inputs().iter() {
    let op_bytes: [u8; 36] = input.previous_output().as_slice().try_into().expect(...);
    if let Some(voter_lock_hash) = dao_outpoint_to_voter.remove(&op_bytes) {
        vote_map.remove(&voter_lock_hash);
    }
}

If any input consumes a DAO outpoint that backed a vote, the corresponding vote is evicted from vote_map. The withdraw-and-revote attack dies at the moment of withdrawal. The instant Alice’s first deposit shows up as a transaction input, her first vote vanishes. By the time her second vote registers from the new address, she is a fresh voter with 1,000 CKB of stake, not 2,000.

This is the kind of check the on-chain vote type script structurally cannot do. The type script runs at vote-creation time and sees one transaction. It can verify the vote cell’s lock matches the DAO deposit it references, but it cannot see what happens to that deposit eight blocks later. Spatial integrity is the type script’s job. Temporal integrity, what changes across the voting window, is the guest’s.


What I am taking from this

The design doc lists nine features. Anonymity is not one of them, and it is not named as an exclusion either. This note was an attempt to name the trade-off implicit in that omission: the design uses CKB’s UTXO-native identity as its dedup primitive, gets stake-weighted uniqueness without manufacturing a new cryptographic primitive in the circuit, and accepts public attribution as the cost.

What stays with me is not “skip nullifiers.” It is something more like: know what your substrate provides before reaching for new primitives. CKB already handed this design one-vote-per-voter through lock-script identity. Adding nullifiers on top would have been redundant for uniqueness, and ineffective for the privacy the stake mechanism leaks regardless.

The harder question Note 1 closed on, how to layer privacy onto this design without breaking what already works, is still open. That is the next note, not this one.


References:


6 Likes

Beyond treasury governance: what privacy would ask of this primitive

This is Note 03 in my ongoing research.

Continuing from Note 2

Note 2 closed on a question I had handed forward from Note 1: how to layer privacy onto this design without breaking what already works. Before going there, I want to be honest about what I think the answer isn’t.

The current voting PoC is designed for Nervos DAO treasury governance. In that context, public attribution is part of the design, not a missing feature. Each vote’s legitimacy depends on its publicly verifiable tie to real on-chain stake. Adding privacy to that link would weaken the mechanism that makes the tally trustworthy.

So I want to say upfront: this note is not a privacy proposal for the existing design. It is a different question. The primitive underneath the voting PoC (zkVM-verified history proof over a block range) is general. It could serve other voting and governance applications. Some of those would need privacy. What would the primitive have to look like to serve them?

That is the design space this note explores. The current implementation is the floor it is built on, not the target it is critiquing.

Use cases that would need privacy

Three scenarios come to mind where privacy has proven to be needed, or clearly is.

Anti-collusion governance (MACI’s domain). When votes are public, a briber can pay only on confirmed delivery. “Vote yes on proposal X, I pay you.” The verification is free if the vote is on-chain. The threat is structural in any DAO where individual votes carry economic weight.

Whistleblower and politically sensitive votes. Boards voting on internal misconduct, members voting on controversial issues, workers voting on union representation. Retaliation is real, and the public ledger becomes the attacker’s tool.

Stake-weighted votes with uneven stake distribution. A nullifier on the vote cell wouldn’t help here. The amount field fingerprints against publicly indexable balances. If three voters hold 1000, 2000, and 5000 CKB respectively, a vote with amount: 5000 identifies the third one. The leak is in the stake, not the cell. (See Note 2.)

The first two are what I named “membership proofs without identity disclosure” in Note 1 The third asks for more: hiding voter weight, not just identity.

The three leak points to address

Three places in the vote cell where identity becomes public. Any privacy proposal has to address all three, or the leak survives.

  1. lock_hash on the vote cell. The current design uses it as the dedup key. Anyone reading the chain learns who voted.

  2. dao_index in the cell’s data. Points at the voter’s DAO deposit cell, which has its own publicly attributable lock script. Even if the vote cell hid the voter’s lock, this pointer would walk back to it.

  3. amount in the cell’s data. The stake weight, published so the guest can verify the tally. Cross-referenced against publicly indexable DAO deposits, it fingerprints the voter when stake amounts are non-uniform.

A nullifier on the vote cell only addresses (1). The leak survives at (2) and (3) regardless. Any honest privacy proposal has to address all three. (Note 2 walks through this in more depth.)

Candidate approaches

There are at least three starting points I have worked through. Each addresses some of the leak points and not others, and none of them feel clean to me.

Direct nullifier addition. Replace lock_hash on the vote cell with a nullifier derived from a voter secret, with a Merkle tree of voter commitments anchoring eligibility. Vote cells carry (nullifier, vote, amount, dao_index).

This fixes leak (1). It does nothing about leaks (2) and (3). The dao_index still walks back to the staker’s deposit; the amount still fingerprints. A reader of the chain still learns who voted, just through a different path. I read this as a building block for the approaches below, not a solution on its own.

MACI-style separation of identity from stake. Identity proven via Merkle membership over voter commitments (anonymous). Stake proven separately via a commitment scheme: voters pre-commit to “I have X stake in this set” rather than referencing a specific deposit. The vote cell publishes a nullifier, a stake-range proof, and the vote choice.

This addresses all three leaks if done right. It requires more circuit work and a separate stake-commitment registry on-chain. The hard part is keeping stake commitments honest. The DAO deposit still has to be verifiable, but the link between deposit and voter has to be hidden. This is the most promising path I can see, and also the most engineering work.

Mixing-pool / stake-aggregation. Voters deposit into a shared anonymous pool. Voting eligibility comes from pool membership, not from individual DAO deposit references. Nullifiers prevent double-voting. The vote cell carries a pool-membership proof instead of dao_index.

This addresses leaks (1) and (2) cleanly but changes the deposit mechanism fundamentally. The existing Nervos DAO interaction would have to be rewired through the pool, and there are liquidity questions (when can voters exit the pool?). It is a well-understood pattern from Tornado-style mixers, but in my read, a heavier change to the DAO model than the other two.

None of these is a free upgrade. The vote cell is not the leak. The stake is. Any honest privacy proposal has to redesign how stake is represented and verified, not just how votes are recorded.

What this would ask of the primitive

The primitive underneath the voting PoC is general by design: prove something happened in blocks N through M, commit the conclusion publicly. The voting design is one application of it. Privacy applications would be others. The question is what would have to change at the primitive layer rather than the application layer to make those work.

Three changes feel load-bearing to me.

Commitments as first-class. Today the primitive proves things about cell data directly: the guest reads amount, sums it, commits the total. For privacy, that has to become: the guest reads a commitment to amount, aggregates commitments, commits an aggregate commitment. The primitive needs to be comfortable working with hidden values, not just cleartext ones.

Aggregation without per-element disclosure. The voting design currently computes yes_vote + no_vote and publishes both. For weight-hiding use cases, the primitive would need to support proving the aggregate is correct without exposing the per-vote weights that produced it. Homomorphic commitment schemes and proof composition are the obvious candidates.

Nullifier-set management as a shared concern. The voting design handles dedup application-specifically. If privacy applications across CKB share infrastructure, nullifier sets need a standard primitive form (how they are stored, updated, and queried) rather than each application reinventing one.

These changes don’t have to land all at once. They are also separable from the voting application itself. My read is that this is the conversation worth having about generalizing the primitive: not “should the voting design have privacy,” but “what does the primitive owe its other applications.”

What I am taking from this

I want this to land clearly: privacy is not the voting PoC’s job. The design was built for treasury governance, where public attribution is the point, not the cost. Adding privacy to it would work against what makes the design fit its use case.

The primitive underneath, though, has a separate life. It can serve voting applications with very different threat models. The privacy-needing use cases I listed are real. Adding privacy to those applications won’t come from a small change to the voting design. It will come from changing how stake is represented and verified, plus targeted changes at the primitive layer.

Where the line between primitive and application actually sits is the kind of question best worked out in conversation with people closer to the design. I would welcome that. The point of this note was to name the design space, not close it.

References:

6 Likes

Greate notes.

I think you already touch the shape of this through the old-cell / new-cell / witness-proof pattern. I wonder whether there is a useful follow-up around the binding layer: how tooling helps make sure the proof is wired to the intended CKB cell transition?

I’ve been planning some adjacent work for CellScript this year, and the longer-term aim is making the DSL a developer-ergonomic glue layer for ZK-on-CKB. Happy to compare notes in the future.

4 Likes

Thank you!
Yeah, the binding layer is something I’ve been digging into too. Your post nudged me to think about it more concretely.I went through the CellScript material and the framing makes sense to me.
On the side, I’ve been thinking about a smaller idea: a lock script that lets a CKB cell only be spent if someone provides a valid ZK proof for a given circuit. It would sit on top of the groth16-ckb verifier I’ve been working on, which is still experimental and unaudited. I see it less as a parallel direction and more as a small starting point for developers to play with while the language-level work matures. It hits the same binding question you raised, just at the lock boundary rather than across a full action body.

I would genuinely love to collaborate on this if you’re open to it. Happy to compare notes, share what I’m drafting, or dig into the binding-layer question together.

4 Likes

Sure, I’d be very happy to explore this direction. Though this week is a bit packed on my side, as I’m preparing a fairly large CellScript update, once it is out on next Monday/Tuesday-ish, I’d be glad to look at this properly.

4 Likes

It’s okay. Feel free to ping me @ceciliamulandi on tg.

3 Likes