Hey everyone,
I’ve been building CKB Governance through the Builder Track - a governance voting protocol built directly on the CKB cell model, rather than an app with a database that happens to settle on CKB. It is live on testnet.
In plain terms: a creator opens a poll > each voter submits their own independent intent cell > anyone can aggregate those intents into deterministic tally lanes without collecting voter signatures > after the deadline anyone can finalize the lanes and close the poll. Voters never compete with each other for the same cell, and no operator needs permission to advance the state. Deposits live in cell capacity and come back through contract-validated refund paths.
The rest of this post is the honest version of how the design got there, including the parts I got wrong and had to tear out, followed by an ask.
Everything is testnet only and unaudited. Use a dedicated testnet account with no mainnet funds.
Try it without building anything
| Role | Link | What to do |
|---|---|---|
| Voter | ckb-voting-dapp.vercel.app | Connect a testnet wallet, vote on an open poll |
| Creator | same app | Create a poll with a short deadline offset |
| Aggregator | same app | Aggregate someone else’s poll without voter signatures |
| Closer | same app | Finalize lanes and close a poll after its deadline |
| Developer | github.com/anihdev/ckb-voting-dapp | Read the Rust contract, build transactions manually. |
Get testnet CKB from the Nervos faucet. Full participation details, including what I most want tested, are in the How to participate section below.
Why I’m posting this
The protocol runs on testnet with enforced state-transition rules. What it does not have yet is real people using it. Every lifecycle path I have exercised so far, I exercised myself, with wallets I control, on a schedule I chose. That is not the same as a protocol meeting actual voters with actual disagreements about timing, wallets, and expectations.
So this post has two purposes: tell the honest version of how the design got here, and ask you to break it.
The journey
Starting from the wrong primitive and when the practice project became a protocol problem
I started this project to learn CKB script development end to end: signatures, witnesses, script arguments, cell data, transaction construction, and wallet integration.
When the voting protocol began, I brought an account-based assumption into the cell model: I put the mutable tally in one place. A poll cell held vote_counts, total_voters, and a growing list of counted voters. Every aggregation consumed that poll cell and produced its next state.
The design worked when one signer controlled the entire lifecycle. That made it a useful practice project, but it did not prove that independent creators, voters, delegates, aggregators, and closers could use it together.
That standard changed after I published the project for review. On May 7, 2026, @chenyukang pointed out that the happy path proved only a single-signer workflow. The review exposed two protocol-level problems: vote intents were controlled by individual voters, and every aggregation competed for the same poll cell.
From that point, the question stopped being “can I build a voting transaction?” It became “can mutually independent actors advance this state machine under CKB’s cell-consumption rules?”
Governance locks, explicit refund ownership, Type ID poll identity, sharded tally state, consensus-aligned timing, force-close recovery, and adversarial CKB-VM tests followed from that change in standard.
Phase A: hardening what I had
The first serious pass was multi-actor validation: a smoke runner where a creator opens a poll, several voters submit intents, and a third party tries to aggregate. That surfaced real bugs: intent lock validation using the wrong script-group context, aggregation scanning that hard-failed on ordinary fee and change cells instead of skipping them.
This phase taught me something that shaped everything after: the tests that find real problems are the ones with more than one actor. A single-signer happy path will pass forever while the protocol is quietly unusable by anyone else.
Phase B: making aggregation permissionless
Originally a vote intent’s output lock had to equal its refund_lock, which meant only the voter could move their own intent. Aggregation therefore needed every voter’s signature. That was fine for a demo with three wallets, but useless at any real scale.
I separated two concepts that I had conflated: who controls the transition and who owns the capacity. Intent cells became governance-locked so the contract controls transitions, while refund_lock stayed in the cell data as immutable refund ownership. A third party could now aggregate without collecting signatures, and close/refund transitions still had to return the exact intent capacity to that encoded lock. The delegated path later exposed a separate funding mismatch: the delegate supplies that capacity even though it returns to the delegator.
The ZK detour, and what it actually taught me
I spent a phase convinced zero-knowledge proofs were the answer to the scalability problem. I reviewed Cecilia’s groth16-ckb, a standalone BN254 Groth16 verifier for CKB-VM built on arkworks, as candidate infrastructure. I also reviewed XuJiandong’s ckb-vote-poc zkVM design as a reference model for proof binding, permissionless settlement, and stake-weighted voting.
Then I wrote down the bottleneck precisely, and the conclusion inverted:
Groth16 can prove that an aggregation step was computed correctly. It cannot change the fact that one live cell can be consumed by exactly one transaction.
If every aggregation still consumes the same poll cell, every aggregator still competes for the same input. A ZK-proven transition loses that race exactly as often as an unproven one. The contention problem was never about verification. It was about where mutable state lives.
This was the most useful wrong turn of the project. ZK could still be interesting later for correctness compression or private eligibility, but it is deferred and outside the current implementation scope.
Both projects stayed useful, just not as the fix I expected: the Groth16 verifier is one candidate I would reevaluate if the completeness track is reactivated, and the zkVM PoC informed how I think about permissionless settlement. Neither is a drop-in replacement here, because this protocol already carries live intent cells, refundable deposits, delegation, and close/force-close paths.
Contention-first: sharded tally lanes
I use tally lane as the user-facing name for one independently consumable tally shard cell. The Rust contract and codec retain shard in names such as CREATE_TALLY_SHARD and shard_id; they refer to the same protocol object.
The fix was structural. Mutable tally state moved out of the poll cell into deterministic tally shard cells, assigned by:
shard_id = blake2b_256(poll_type_hash || voter_lock_hash)[0..8] as LE u64 % shard_count
CREATE_POLL now atomically creates the poll and its complete ordered shard set. Aggregation consumes pending intents for one shard and updates only that shard; the poll cell is a dependency, not an input. Transactions updating different lanes therefore do not share a mutable tally input.
The routing is fully determined by the voter’s own lock, so any aggregator computes the same destination without coordination:
PollCell (cell dep, never consumed)
|
intent(voter A) ----+ |
intent(voter B) ----+-> blake2b_256(poll_type_hash || voter_lock_hash)
intent(voter C) ----+ [0..8] as LE u64 % shard_count
intent(voter D) ----+
|
+-------------+------+------+-------------+
v v v v
TallyLane 0 TallyLane 1 TallyLane 2 TallyLane 3
(A, C) (B) (empty) (D)
Aggregator X consumes TallyLane 0 -> new TallyLane 0
Aggregator Y consumes TallyLane 3 -> new TallyLane 3 (concurrent, no conflict)
Aggregator Z also targets TallyLane 0 (races X, one wins)
Sharding does not eliminate serialization, it partitions it. Two aggregators working on the same shard still race. Two working on different shards do not.
To keep direct-close transactions bounded and predictable, the protocol permits direct close only for polls with at most eight lanes. Larger polls use MERGE_TALLY_SHARDS to reduce up to eight finalized lanes or prior disjoint merge results per transaction before close.
I wrote up the full reasoning behind this change, including the failure modes I hit and the flow diagrams, in Sharded Aggregation Explained.
The timing problem I did not expect
A CKB script cannot read the epoch of the block that will eventually include its transaction. And since a transaction chooses its own header_deps, a caller-supplied header is not authenticated time either. So “did this vote arrive before the deadline?” is genuinely hard.
The protocol resolves it with two different consensus-backed mechanisms:
- Intent cutoff is authenticated from the consumed intent cell’s creation header, loaded via
Source::Input. The voter cannot forge when their own cell was created. - Finalization and close pin the protocol input at index 0 and require an absolute-epoch
sincelower bound, which CKB consensus enforces.
CKB’s former four-epoch header-dependency immaturity rule was removed by RFC 0036. The protocol can therefore reference a recent intent’s creation header; applications still choose their own confirmation depth and reorganization tolerance.
A consequence worth stating plainly: a transaction signed before the deadline but included after it is late, because inclusion creates the cell, not signing.
epoch -> ......... N ......... N+1 ......... N+2 ......... N+3 .........
|
deadline
intent 1
created [block @ N] |
aggregated ------------------> [tx @ N+2] | timely: creation header epoch N <= deadline
|
intent 2 |
signed (off chain, epoch N+2) |
created [block @ N+3] ---------------------------> late: creation header epoch N+3 > deadline
| exact full-capacity refund to refund_lock
|
finalize / close |
input 0 since = absolute epoch > deadline ---+---> [tx @ N+3+] enforced by consensus
force-close |
input 0 since = absolute epoch > deadline + FORCE_CLOSE_GRACE_EPOCHS
The left column is what the voter does; the bracketed blocks are what consensus records. Only the bracketed part is authenticated.
Replacing growing voter lists with fixed-root tally lanes
The last major change fixed a growth problem I had shipped without thinking hard enough about. Each shard stored the full list of counted voter hashes: 32 bytes per voter, forever. Occupied capacity grew with turnout, which meant the deposit needed to hold a lane depended on how many people voted in it.
Now each lane stores one constant-size 32-byte sparse-Merkle root. In plain terms, that root commits to the represented-voter keys already counted without storing the complete list in the cell. Aggregation carries a versioned compiled multi-proof in the lane input’s witness; the contract derives voter keys from the intent inputs and verifies every key was absent under the input root and present under the output root. Lane size is now constant regardless of turnout, and the same represented-voter key cannot be counted twice in a lane.
The tree is Nervos’ own sparse-merkle-tree, pinned to one revision so the contract, the browser proof provider, and the Node tooling cannot drift onto different tree rules. Exact key/value conventions, measured CKB-VM cycle costs, and the remaining operational limits are in the implementation record.
What the difficulty actually taught me
None of this was smooth. Almost every section above began as something I had already built and believed was finished, and each redesign meant going back into working code and taking it apart. The single-cell tally, the voter-signature requirement, the ZK detour, the growing voter lists: I shipped each of those and then had to undo them.
What I did not expect is how much the corrections would teach me. CKB does not let you carry account-model habits in quietly. The cell model surfaces the assumption as a contention bug, a capacity bug, or an authorization hole, and no amount of application code papers over it. Working through that changed the questions I ask about any state transition: who owns the capacity, who authorizes the change, which cell is consumed, and what a second independent actor can do in the same moment. That is a sharper set of questions than I had when I started, and it applies well beyond this protocol. The hardest parts of this project turned out to be the parts that made me a better developer.
What is actually live
The deployed lifecycle is:
- A creator creates one Type ID-backed poll and its complete ordered tally-lane set atomically.
- Participants create independent direct or delegated vote intents. The poll creator cannot vote in that poll under the current wallet-role rule.
- Any wallet can aggregate timely pending intents into their deterministic lanes without consuming the poll cell or requiring voter signatures.
- After the deadline, any wallet can finalize each lane. Polls with more than eight lanes use bounded merge-result transactions before close.
- The creator can close when the tally state is ready, or anyone can force-close after the grace period. Separate recovery paths return qualifying omitted intent deposits.
As a flow, including the branch that most reviewers will not have exercised:
CREATE_POLL (poll cell + complete ordered lane set, atomic)
|
v
CREATE_VOTE_INTENT (independent cells; creator may not vote)
|
v
CREATE_TALLY_SHARD / aggregate <--+ any wallet, no voter signatures
| | timely intents only
+----------------------------+
|
deadline passes
|
v
CREATE_TALLY_SHARD / finalize (1..8 ordered same-poll lanes per tx)
|
v
lanes <= 8 ? ---- yes ----> CLOSE_POLL (creator)
| ^ or FORCE_CLOSE after grace
no |
| |
v |
MERGE_TALLY_SHARDS <--+ |
(<= 8 lanes or prior | |
disjoint results) | |
| | |
one result ? -- no ---+ |
| |
yes ---------------------->-+
side flows: DELEGATE / revoke, late-intent refund, omitted-intent recovery
Deployed to CKB testnet as a new code cell on August 5, 2026:
- code hash:
0xb2c2ea67113fba954966700558ceb6121abb3935076c5165986d1586bcfbd954 - contract tx:
0x5a3ecd82...06ae9d5 - committed block: 21,983,614
- release ELF: 125,376 bytes
Test coverage: 63 CKB-VM integration tests running the release RISC-V binary, plus 139 focused TypeScript tests over codecs, real CCC transaction builders, and lifecycle logic. ckb-testtool executes the scripts but is not a full live-node consensus rehearsal, which is why the community test below still matters.
The contract finalizes counts, not a governance decision. It does not define quorum, pass/fail thresholds, tie-breaking, a protocol-level winner, or execution. A leader or tie shown by the reference UI is presentation over the finalized counts; the consuming application decides what those counts mean and what happens next.
Also: no formal audit, no mainnet, no automatic treasury execution.
How to participate
This is the ask. The protocol needs activity from wallets I do not control, on a timeline I did not pick.
Everything below is testnet only. Use a dedicated testnet account with no mainnet funds. Vote choices, intent cells, participating wallet locks, delegation cells, and transaction history are public on-chain, so do not use sensitive questions or choices.
Get testnet CKB from the Nervos faucet, open https://ckb-voting-dapp.vercel.app, and connect.
Creating polls, intents, and delegations locks testnet CKB as cell capacity. Valid close, revoke, and refund paths return the capacity to the protocol-defined recipient; transaction fees are not refunded.
Community test flow
These actions are available through the reference app and do not require custom transaction construction:
- Vote on an open poll. The baseline signal. Real wallets, real intent cells.
- Create a poll with a short deadline offset. One or two epochs are useful for a coordinated test, but an epoch targets roughly four hours rather than guaranteeing it. The current fractional epoch and the protocol’s strict
current_epoch > deadlineboundary can make the effective window longer, so use the estimate displayed by the app. - Aggregate someone else’s poll. You do not need the voters’ signatures. This is the permissionless path that took two phases to get right, and it has never been run by a stranger.
- Finalize lanes and close a poll after its deadline. This is the least-rehearsed part of the entire protocol and where I most expect something to be wrong.
- Delegate to another wallet, then revoke it. Note the funding asymmetry above and tell me how confusing it is in practice.
- Recover a deposit when the app exposes the action. If an indexed intent qualifies as late or was omitted from close, use the displayed recovery path and verify the returned capacity.
Developer and adversarial review
The normal UI intentionally prevents several invalid actions. Developers reviewing the builders or constructing transactions manually can help by testing conflicting intents, stale lane updates, early close, force-close before grace, malformed sparse-Merkle proofs, wrong poll scope, unexpected scripts, and input/output reordering. If the contract accepts something that should fail, that is the most valuable report you can send me.
Please report results in this Nervos Talk thread or on the CKBuilder project issue. Include:
- the action and role you were testing;
- the poll ID and transaction hash, when available;
- what you expected and what happened instead;
- the wallet connector and browser used;
- any error text shown by the app.
What I’m asking reviewers
Beyond bugs, these are the design questions I am genuinely unsure about:
- Uniqueness source. For an open wallet-only poll, CKB gives no canonical single cell per lock hash. Is there a construction I am missing that yields at most one voting authority per
(poll, principal)without a permissioned registry? - Completeness. Is “correct over what was aggregated” an acceptable guarantee for real governance, or does a usable protocol need a completeness proof or an economic guarantee that operators aggregate everything?
- Maintenance incentives. Creator-funded bounties, Fiber-based reimbursement, or managed operators: what actually works on CKB?
- Tally-lane count. The protocol builder accepts 1-256 lanes, while the reference UI currently uses eight and does not expose that choice. What heuristic should an SDK expose to a non-technical DAO admin who has no basis for selecting a count?
- Timing model. Is the creation-header cutoff the right approach, and are there attacks on it I have not considered?
The Rust contract is the authority for all of this: entry.rs for validation, and codec.rs for byte layouts. If documentation and contract ever disagree, the contract is correct and the docs are a bug.
Where this is going
The direction is extracting the working protocol into a reusable DAO/SubDAO Builder SDK: contracts, TypeScript transaction builders, React hooks, eligibility adapters, and a reference dashboard. Other CKB projects should be able to add governance without rebuilding the cell lifecycle. The framing shift from “another DAO app” to “governance infrastructure” came out of ecosystem feedback and I think it was correct.
Vote on something. Try to break something. Tell me what you find.
Acknowledgements
- @chenyukang, whose public review exposed the single-signer authorization problem and shared poll-cell contention that drove the multi-actor redesign.
- Neon, whose mentorship helped in the product framing, Valid Resource provisions and encouragement and broader community testing.
- Cecilia Mulandi and XuJiandong, whose public verifier and voting research informed my distinction between proof verification and cell-state contention.
- The authors and contributors behind the CKB RFCs, CCC,
ckb-testtool,sparse-merkle-tree, and the linked governance discussions that informed the implementation and review questions throughout this post.
These acknowledgements describe technical influence and feedback, not endorsement, delivery responsibility, or a partnership. CKBoost, Mint Gate, Vellum, and the other ecosystem projects listed below remain possible future adapter contexts rather than collaborators or dependencies.
