The CKB Community Fund DAO V1.1, currently deployed on a mainnet testbed and scheduled for full mainnet deployment on March 16, 2026, contains a voter whitelist mechanism that the steward team promised to remove but has not. Code analysis across all layers (on-chain contract, backend, frontend, documentation) proves the whitelist is a mandatory access control gate, not an optimization. The steward team’s public statements that “anyone with staked CKB can vote” are contradicted by the implementation. The centralization risk Phroi (@phroi) raised is valid.
| Claim | Finding |
|---|---|
| “Only for testing, will be removed at mainnet” | Still in code, docs, and deployed testbed on the eve of mainnet deployment |
| “Doesn’t ban anyone from voting” | On-chain contract rejects votes without valid SMT proof |
| “Anyone with staked CKB can vote” | Requires Web5 DID registration and inclusion in daily whitelist |
| “Only accelerates finding staked CKB info” | Is a cryptographic access control gate enforced by type script |
| “Will update docs to reflect removal” | Docs unchanged since January 2026, all whitelist references intact |
The code analysis also identified bugs and security gaps unrelated to the whitelist (Sections 4 and 5):
- Governance identity (private signing key) stored unencrypted in browser localStorage with no mandatory backup
- A second, undisclosed whitelist (
--whitelistCLI argument) that blocks proposal creation by DID - Unauthenticated debug endpoints exposed in production
- Test code overriding the 7-day vote duration with an ~8-minute window
- A format mismatch bug that breaks one of two vote creation paths
- Hardcoded testnet network type across all address parsing
1. Background
The CKB Community Fund DAO V1.1 is an upgrade to the Nervos CKB community governance system. It introduces a Web5-based voting platform with on-chain vote verification via smart contracts. The proposal was discussed over six months on Nervos Talk (113 posts, 16 participants, Sep 2025 - Mar 2026) and approved as a meta-rule change with a $100,000 USD budget.
The DAO is overseen by a three-member Management Committee: Jan (@janx), Terry (@poshboytl, original DAO rule designer), and Cipher (@Cipher). Terry investigated the Metaforo vote manipulation exploit that reversed the initial V1.1 vote result (Section 1.1 timeline, Nov 2025). A 2/3 committee majority can act on governance matters.
The public-facing V1.1 team members referenced in this report: Zhouzhou (@zz_tovarishch, ops lead, proposal author), David (@david-fi5box, dev lead, Web5 core developer and author of the app_view backend and web5fans repos), Haoyang (@_magicsheep, steward team member), and Night Lantern (@NightLantern, steward team member).
1.1 Timeline
- 2025-09-04: DAO V1.1 proposal posted on Nervos Talk
- 2025-09-10: Reclassified as meta-rule change after community feedback
- 2025-11-12: Metaforo vote manipulation exploit discovered during the V1.1 proposal vote by Terry (@poshboytl, DAO Management Committee). Unbind/rebind of same Nervos DAO address allowed repeated voting; 71.2M CKB weight exploited. Proposal passed after corrected tally (75.2% approval)
- 2026-01-23: Phroi raised whitelist concern on Nervos Talk post 97 and in the Nervos Network Telegram group. Steward Haoyang responded: “this feature is only there for testing purpose, and it will be removed after the mainnet launch”
- 2026-02-12: “Quasi-production deployment” announced for Feb 14 (Nervos Talk post 102). Platform deployed as testbed, initially on CKB testnet
- 2026-03-01: Backend switched from CKB testnet to CKB mainnet (Nervos Talk post 114). Platform now running as mainnet testbed
- 2026-03-07: Phroi reported whitelist still blocking proposal creation on the mainnet testbed (Nervos Talk post 115). No reply as of March 15 (8 days). Post 115 is the last post in the thread
- 2026-03-15 (code): Whitelist remains in all repositories and documentation
- 2026-03-15: Mainnet launch announced by the steward team
- 2026-03-15 (Telegram): Phroi asked about the whitelist in the Nervos Nation Telegram group; steward Night Lantern (@NightLantern) deferred to Haoyang, who was unavailable (timezone)
- 2026-03-16: Scheduled mainnet deployment
1.2 The Steward Team’s Claims
Haoyang (@_magicsheep, steward team member) made two key claims in the Nervos Network Telegram group (later forwarded by Phroi to Nervos Talk post 97):
Claim 1: “this feature is only there for testing purpose, and it will be removed after the mainnet launch”
Claim 2: “the current whitelist mechanism doesn’t ban anyone from voting, it’s not like only the whitelisted addresses can vote, anyone with staked CKB can, the whitelist only accelerate the speed for the current system to find the information about who has staked CKB, it doesn’t prevent any addresses from voting”
Both claims are contradicted by the implementation, as documented below.
2. The Centralization Risk
The backend operator controls who can vote, which votes are counted, and what the result is.
Who can vote. Every night, the backend rebuilds a voter whitelist from its own database and publishes the result as a cryptographic root hash on-chain. Anyone not in that list is blocked from voting, no matter how much CKB they have staked in Nervos DAO.
Which votes are counted. The vote tally does not read from the blockchain. It reads from a separate indexer service that stores vote records in its own database and serves them over an unprotected API. The backend never cross-checks this data against the chain. The operator runs both.
What the result is. Vote weights are computed from two sources: Nervos DAO deposits (queried from the chain) and address binding relationships (queried from a separate, unprotected indexer). The backend combines them, computes the tally, and determines the outcome. The on-chain contract verifies voter identity but not voting power.
The community has no way to independently verify any of these steps. The codebase provides no audit tool, no on-chain result commitment, and no signed tally transcript. The documentation claims the process is “open and verifiable” (technical-overview.mdx:159), but nothing in the system makes that possible.
One might expect fund custody to provide a check, but it does not. Each approved project gets a 2-of-3 multisig wallet (fund-management.mdx), and 2 of the 3 signers are stewards from the team that operates the backend. The remaining signer is a community observer who cannot veto. The multisig signers have no tool to independently verify vote results on-chain: they see the backend’s reported state and are expected to “fulfill the procedural obligation of signing for payment after each milestone is confirmed by a community vote” (dao-stewards.mdx:34).
The same team also controls who gets administrator access. The admin table, which gates all privileged API endpoints, is populated by direct SQL INSERT at deployment time (administrator.rs). No API exists to add or remove administrators after that. Whoever sets up the database decides who the admins are.
None of this requires bad intent to be a problem. The architecture has no separation of powers, so whoever operates the backend, now or in the future, can influence governance outcomes without detection. The documentation envisions community elections for the steward team “after the DAO Stewards team has been operational for one year” (dao-stewards.mdx:68) and a “vote of no confidence” mechanism (dao-stewards.mdx:82), but neither is implemented in code. The first steward team is self-appointed (“guided by the proposal team”, dao-stewards.mdx:64). When elections do happen, they would run through the same V1.1 voting system. If the whitelist is still in place at that point, the operator decides who votes on whether to keep the operator.
3. Technical Analysis
3.1 On-Chain Smart Contract
Repository: CCF-DAO1-1/ckb-dao-vote (forked from web5fans/ckb-dao-vote)
The vote type script uses a Sparse Merkle Tree (SMT) to enforce voter eligibility. The VoteMeta cell stores an optional smt_root_hash field.
Source: contracts/ckb-dao-vote/src/entry.rs, lines 81-108
let root_hash = vote_meta.smt_root_hash()?;
let iter = QueryIter::new(load_cell_type, Source::GroupOutput);
for (index, _) in iter.enumerate() {
let vote_proof = load_vote_proof(index)?;
let hash: [u8; 32] = vote_proof.lock_script_hash()?;
// Only users included in the SMT can vote (restricted vote)
if root_hash.is_some() {
let root_hash: [u8; 32] = root_hash.unwrap();
let proof = vote_proof.smt_proof()?;
let proof: Vec<u8> = proof.try_into()?;
let smt_builder = SMTBuilder::new();
let smt_builder = smt_builder
.insert(&hash.into(), &SMT_VALUE.into())
.map_err(|_| Error::VerifySmtFail)?;
let smt = smt_builder.build().map_err(|_| Error::VerifySmtFail)?;
// step 4
if smt
.verify(&root_hash.into(), &proof)
.map_err(|_| Error::VerifySmtFail)
.is_err()
{
#[cfg(feature = "enable_log")]
log::info!("SMT verify failed. Not on tree.");
return Err(Error::VerifySmtFail);
}
}
// ... steps 5-6: lock script validation, vote choice validation
}
Behavior: The type script iterates over every vote output cell in the transaction (line 83). For each, it loads the VoteProof from the witness and extracts the voter’s lock_script_hash (lines 85-86). When smt_root_hash is set (which it always is in production: build_vote_meta unconditionally wraps the root in Some), the type script rejects any vote that does not include a valid SMT membership proof.
The contract’s own documentation (docs/ckb-dao-vote.md, lines 34-35) confirms: “When set: Only users included in the SMT can vote (restricted vote). When None: All users can vote (open vote).”
Authority over the VoteMeta cell (who can create, update, or consume it) is governed by its lock script, not by the vote type script. This is standard CKB architecture. The centralization risk is not in the on-chain contract but in the off-chain backend that decides the whitelist contents before the VoteMeta cell is deployed (Section 4.1).
3.2 Backend Service
Repository: CCF-DAO1-1/app_view
The backend is a Rust Axum service that manages whitelist generation, proof distribution, and vote metadata creation.
Whitelist Generation
Source: src/scheduler/build_vote_whitelist.rs, lines 39-97
A daily cron job (src/scheduler/mod.rs:14, expression "0 0 0 * * *" = midnight) builds the whitelist:
- Iterates all registered Web5 DID entries in the
profiletable - For each DID, resolves to a CKB address
- Checks Nervos DAO deposit amount via
get_nervos_dao_deposit() - Only includes addresses with deposit > 0
- Builds an SMT from all qualifying lock hashes
- Stores root hash and member list in the VoteWhitelist table
Eligibility requires both a registered Web5 DID and a Nervos DAO deposit. Users who stake CKB but have not registered a Web5 DID are excluded.
Whitelist Effect on Vote Outcomes
The whitelist controls who can create vote cells on-chain (voter access), not how votes are weighted. Voting weight is determined separately: after the voting period ends, the check_vote_meta_finished scheduler computes each voter’s weight from their on-chain Nervos DAO deposit plus bound deposits (get_weight).
The whitelist’s effect is voter exclusion: anyone not in the list cannot cast vote cells, so their CKB weight is never counted in the tally, even though their on-chain deposits are real. CKB’s governance uses CKB-weighted voting with agreement and quorum thresholds, so excluding high-weight voters can change the outcome.
Address Binding and Weight Consolidation
“Binding” in V1.1 is same-user address consolidation: a user cryptographically proves they own both a cold wallet (e.g. Neuron, Address A) and a web wallet (Address B), and the system combines both deposits into one voting weight (address-binding.mdx). In normal usage, the whitelist is built from DID primary addresses only, so bound cold-wallet addresses cannot vote independently. The weight amplification that arises when this assumption is violated via Sybil identity is documented in Section 4.3.
Unverified Indexer Architecture
The tally function calls all_votes(), which is a plain HTTP GET to {indexer_vote_url}/all-votes with no signature verification, no CKB chain cross-check, and no TLS pinning. The response JSON is fed directly into the tally loop.
The vote indexer (web5fans-indexer) reads vote cells from CKB blocks (ckb.rs:311-390), stores them in PostgreSQL, and serves them over HTTP (router.rs:65-117). The backend has a CKB RPC client and uses it to read vote cells for the detail endpoint, but never cross-checks this data against the indexer data used for the tally (and the detail endpoint is itself broken on mainnet due to a hardcoded testnet type_id, Section 5.4). Between the chain and the tally there is an unprotected database and an unauthenticated API. The exploitation scenario this enables is documented in Section 4.2.
The indexer query also provides the sole deduplication of vote cells per voter (DISTINCT ON(address) in query_vote_records_by_epoch_opt). The on-chain contract does not enforce one-vote-per-voter: a whitelisted voter can submit multiple vote cells in one transaction (entry.rs:110 checks that the voter’s lock hash exists in inputs via .any(), not that it is consumed 1:1), and all pass validation.
The DISTINCT ON clause is what prevents these from being counted multiple times in the tally. This is not a vulnerability in the current system, but it means voter deduplication is another correctness property that depends entirely on the unverified indexer.
The on-chain vote type script verifies voter identity (lock hash in SMT + lock hash in inputs) but not voting power or voting period. The VoteMeta cell carries start_time and end_time fields, but the type script never reads them: it checks SMT membership (line 88), lock hash ownership (line 110), and candidate validity (lines 115-130), nothing else. Time range enforcement is purely off-chain (the tally function at check_vote_finished.rs:94-100 compares the database end_time against the current epoch, and the indexer filters vote records by epoch). The only on-chain mechanism to close a vote session is consuming the VoteMeta cell (Section 4.9). Nervos DAO deposit amounts are queried from CKB RPC (on-chain), but binding relationships come from an unauthenticated external HTTP indexer. Vote weights and the final tally are computed by the backend.
Proof Distribution
Source: src/api/vote.rs, lines 137-189
The get_proof() function reconstructs the SMT from the stored whitelist (lines 151-160), generates a membership proof for the requested lock hash (lines 171-177), and then verifies the proof server-side (lines 181-183). If verification fails (i.e., the voter’s lock hash is not in the tree), it returns Err(eyre!("Not in the whitelist")) (line 187) and the voter cannot obtain a valid proof.
Vote Creation
Source: src/api/vote.rs, lines 222-293
The create_vote_meta endpoint:
- Requires administrator credentials (verified against
administratortable, lines 230-236) - Reuses an existing VoteMeta row when one is found for the same proposal (lines 255-259). When creating a new row, sets
whitelist_idto today’s date (chrono::Local::now().format("%Y-%m-%d"), line 269), referencing the daily whitelist - Calls
build_vote_meta(line 282) which reconstructs the SMT from the stored whitelist and unconditionally setssmt_root_hashtoSome(...)(lines 739-742) - There is no code path to create a vote with
smt_root_hash = None
3.3 Frontend
Repository: CCF-DAO1-1/ckb-fund-dao-ui
Source: src/utils/votingUtils.ts, lines 230-645
The frontend vote flow spans two functions:
handleVote (lines 230-351) calls /api/vote/prepare via prepareVote() (lines 237-241). If the backend returns an error (e.g., voter not in whitelist), the vote fails before reaching transaction construction.
buildAndSendVoteTransaction (lines 365-645) receives the proof data as a parameter and:
- Validates proof exists and is an array (lines 405-407)
- Constructs VoteProof from the voter’s lock script hash and SMT proof, then serializes it to bytes (lines 425-433). Places the serialized VoteProof into the witness
output_typefield via WitnessArgs (lines 565-604). Note: the frontend Molecule codec (src/utils/molecules.ts, lines 89-101) uses different field names (vote_script_hash,user_smt_proof) than the on-chain contract (lock_script_hash,smt_proof). This works because Molecule serializes by field position, not name, but the naming is misleading:vote_script_hashactually contains the voter’s lock script hash, not the vote type script hash. - Without a valid proof from the backend, the frontend cannot construct a valid vote transaction. Even if a user bypassed the frontend and submitted manually, the on-chain type script would reject it
Source: src/locales/en.json, line 1015
Error message: “missingProof”: “Vote data missing proof field or incorrect format”
3.4 Documentation
Repository: CCF-DAO1-1/ccfdao-v1.1-docs
The documentation explicitly describes the whitelist as mandatory.
Source: content/docs/en/user-guide/faq.mdx, line 75:
“Voting in DAO v1.1 is implemented through smart contracts. When a proposal initiates voting, the system collects the Owner addresses (CKB addresses) corresponding to all currently registered Web5 DID accounts as the voting whitelist. Only Web5 DID accounts on the whitelist can participate in voting for that proposal.”
Source: content/docs/en/developer-docs/architecture/vote.mdx, line 140:
“smt_proof: Cryptographic proof that this lock_script_hash is in the authorized voter whitelist”
Source: content/docs/en/user-guide/getting-started/bind-nervosdao-address.mdx, line 138:
“Voting whitelist timing: After binding an address, it will only be included in the whitelist when the next proposal initiates voting”
The documentation has not been updated since January 2026. All whitelist references remain intact.
3.5 Branch Analysis
All repositories were checked for branches that modify or remove the whitelist behavior. Every remote branch was fetched and diffed against main.
CCF-DAO1-1/ckb-dao-vote: 1 branch (main) plus 1 merged PR (deploy-mainnet). The SMT verification logic in entry.rs is identical across both. The upstream web5fans/ckb-dao-vote also has only main. The only difference between the two repos is mainnet deployment artifacts.
CCF-DAO1-1/app_view: 1 branch (main), introduced in a single squashed commit (optim log). No other branches or tags exist. The build_vote_meta function always sets smt_root_hash to Some(...). There is no “open vote” code path.
CCF-DAO1-1/ckb-fund-dao-ui: 4 branches (main, dev, and two vercel/* security patches). The dev branch has ~180 commits ahead of main with significant refactoring of the vote transaction builder, but the SMT proof requirement in votingUtils.ts is structurally identical: proof is fetched, validated, encoded into VoteProof, and placed in the witness. The two vercel/* branches are automated security patches unrelated to voting.
CCF-DAO1-1/ccfdao-v1.1-docs: 1 branch (main). All whitelist references in vote.mdx, faq.mdx, and bind-nervosdao-address.mdx are intact. Zero commits match “whitelist”, “snapshot”, or “remove” in their messages.
No branch in any repository modifies or removes the whitelist enforcement.
4. Exploitation Scenarios
The following scenarios were identified through source code analysis.
| # | Scenario | Requires | Effect | Severity |
|---|---|---|---|---|
| 4.1 | Whitelist curation | DB access | Controls who can vote | Critical |
| 4.2 | Unverified indexer | Indexer/network access | Controls which votes count | Critical |
| 4.3 | Sybil weight amplification | Two DIDs + DAO deposits | Inflates voting power | High |
| 4.4 | Proposal state bypass | Network access (unauth) | Sets any proposal to any state | Critical |
| 4.5 | DID-level proposal gate | Operator (CLI arg) | Blocks proposal creation by DID | High |
| 4.6 | Stale whitelist timing | Proposal creation rights | Excludes recently registered voters | Medium |
| 4.7 | Whitelist rebuild trigger | Network access (unauth) | Creates incomplete whitelist | Medium |
| 4.8 | Selective proof denial | Operator access | Blocks specific voters without trace | High |
| 4.9 | Premature vote termination | VoteMeta lock key | Closes vote session early | High |
| 4.10 | Voter enumeration | Network access (unauth) | Leaks voter list and eligibility | Info |
4.1 Insider: Administrator Curates the Voter List
Requires: Database access to the backend PostgreSQL instance.
The VoteWhitelist.insert function uses ON CONFLICT ... UPDATE semantics (lines 52-55), so any write to the whitelist table with the same ID overwrites the previous one without error. Both vote creation paths read the whitelist from this table: create_vote_tx (used by initiation_vote) selects the most recent row; create_vote_meta queries by date-format ID (though this path is broken per Section 5.3). Either path then calls build_vote_meta, which fetches the whitelist from the database and embeds the SMT root hash on-chain.
Anyone with database access can:
- Wait for the nightly whitelist cron job to run
- Directly UPDATE the
vote_whitelisttable to add or remove addresses - Any subsequent vote creation references the modified whitelist
- The on-chain VoteMeta cell now contains the SMT root hash of the modified list
- Removed voters call
/api/vote/prepareand receive “Not in the whitelist”
The on-chain root hash alone reveals nothing about who is or isn’t included. The backend does store the full member list in the database and exposes it via /api/vote/whitelist (unauthenticated, returns all lock hashes), so a community member could in principle reconstruct the SMT and compare the root hash against the on-chain value. However, this endpoint is affected by the same ID format mismatch as Section 5.3 (queries by "%Y-%m-%d" but the cron job stores RFC 3339 IDs), and even when obtainable, the list reflects the database state at query time, not at vote-creation time. If the database is modified between the API response and the on-chain deployment, no discrepancy is visible to the community.
A chain audit tool (Section 7) that reconstructs the SMT from all registered DIDs and compares the root against the on-chain VoteMeta would expose any post-cron modification.
4.2 Insider: Vote Tally Trusts an Unverified Indexer
Requires: Operator access to the vote indexer or the network path between indexer and backend.
The unverified indexer architecture described in Section 3.2 means the operator controls the entire data path from chain to tally. Vote records in the indexer’s PostgreSQL database can be added, omitted, or altered without detection. Both the backend (CCF-DAO1-1/app_view) and the indexer (web5fans/web5-indexer) are maintained in the same developer organization (web5fans), built from the same container registry (registry.devops.rivtower.com/web5/), and connected via operator-configured URLs (dao/sts.yaml:52-57). In the standard deployment model, the operator runs both. An audit tool (Section 7) reading vote cells directly from a CKB node and recomputing the tally would reveal any divergence from the indexer’s reported results.
4.3 Design: Vote Weight Amplification via Sybil Binding
Requires: Two Web5 DIDs with Nervos DAO deposits (one person, two browser profiles).
The get_weight function adds the voter’s own Nervos DAO deposit (line 52) plus all deposits from addresses bound to them via the bind indexer (lines 54-67). The tally loop (check_vote_finished.rs:135-176) calls get_weight() independently for each voter and accumulates the results with no cross-voter deduplication.
Binding is unilateral: the source address signs a BindInfo with the target Script, and the bind indexer stores the mapping after verifying only the source signature (address-binding.mdx:117-122). The target address does not consent. BindInfo uses the default secp256k1-blake160-sighash-all lock, so any CCC-supported wallet (UTXO Global, etc.) can sign it. Creating a second Web5 DID requires only a second browser profile. get_weight skips self-bindings (indexer_bind.rs:63-64: if from == ckb_addr { continue; }), so the attack requires two distinct addresses.
If Alice (100,000 CKB in DAO, DID 1) binds to Bob (50,000 CKB, DID 2) and both vote: Alice’s weight is 100,000 and Bob’s weight is 150,000 (own 50,000 + bound 100,000). Total counted: 250,000 from 150,000 real CKB. With a star topology (n addresses all bind to one center), the center’s weight includes all n deposits plus its own. Binding is not transitive (get_weight does a single-level query_by_to), so chaining does not propagate further. The whitelist generation (build_vote_whitelist.rs:54-84) uses get_nervos_dao_deposit() which returns the voter’s own DAO cells regardless of binding status, so bound addresses remain on the whitelist and can vote. An audit tool (Section 7) comparing total counted CKB weight against total on-chain Nervos DAO deposits would reveal the inflation.
4.4 External: Unauthenticated Proposal State Bypass
Requires: Network access to the API (no authentication needed).
The /api/proposal/update_state endpoint is an unauthenticated POST request. It carries the same debug comment as the whitelist rebuild endpoint (Chinese: 方便调试用的,请勿随意调用). It takes a proposal URI and a target state integer, then directly updates the database with no authorization check:
pub async fn update_state(
State(state): State<AppView>,
Query(query): Query<StateQuery>,
) -> Result<impl IntoResponse, AppError> {
query.validate()
.map_err(|e| AppError::ValidateFailed(e.to_string()))?;
let lines = Proposal::update_state(&state.db, &query.uri, query.state).await
.map_err(|e| { /* ... */ AppError::ExecSqlFailed(e.to_string()) })?;
if lines == 0 { return Err(AppError::NotFound); }
Ok(ok_simple())
}
(Source: proposal.rs:382-402)
The validate() call is format validation (via the Validate derive on StateQuery), not authorization. No DID check, no admin table lookup, no signature verification. Compare with every admin-protected endpoint (e.g. send_funds, create_vote_meta), which all verify the caller’s DID against the administrator table and check a cryptographic signature.
Any unauthenticated client can set any proposal to any state in the lifecycle (Draft = 1, InitiationVote = 2, WaitingForStartFund = 3, InProgress = 4, …, Completed = 9). This bypasses every governance gate that depends on proposal state: voting, quorum thresholds, admin approval, and fund-release prerequisites.
Reproduction:
curl -X POST 'https://<host>/api/proposal/update_state?uri=at://did:example/proposal/1&state=9'
This endpoint alone does not move CKB on-chain: fund transfers are manual (an administrator calls /api/task/send_funds, which records a self-reported tx_hash and amount in the database but does not construct or submit a CKB transaction). However, it can force proposals to incorrect states and remove the voting gate that would otherwise prevent fund-release tasks from being created. The backend enables permissive CORS (main.rs:206), so this endpoint is callable cross-origin from any website via JavaScript: a page visited by any community member could trigger state changes without the visitor’s knowledge.
4.5 Insider: DID-Level Proposal Whitelist
Requires: Backend operator access (controls the --whitelist CLI argument).
The backend has a second, separate whitelist independent of the SMT-based vote whitelist analyzed in Sections 3.1-3.5. The --whitelist CLI argument (main.rs:54) accepts a comma-separated DID list. When non-empty, record.rs:58-65 rejects proposal and reply creation (and editing, via the same check in record.rs:173-180) for DIDs not in that list. The field new_record.repo contains the user’s DID (ATProtocol convention: a user’s data repository is identified by their DID):
if !state.whitelist.is_empty() && !state.whitelist.contains(&new_record.repo) {
match record_type {
NSID_PROPOSAL | NSID_REPLY => {
return Err(eyre!("Operation is not allowed!").into());
}
_ => {}
}
}
This is a DID-level gating mechanism at the record creation layer. Phroi reported being unable to create proposals on the mainnet testbed (March 7, 2026, Nervos Talk post 115). The error message received would have been "Operation is not allowed!" if this whitelist was the cause.
4.6 External: Stale Whitelist via Timing
Requires: Any user with proposal creation rights (100,000 CKB minimum weight, including bound deposits per Section 4.3).
The same create_vote_tx function selects the whitelist by taking the most recent row from the database:
let (sql, value) = VoteWhitelist::build_select()
.order_by(VoteWhitelist::Created, Order::Desc)
.limit(1)
.build_sqlx(PostgresQueryBuilder);
(Source: src/api/mod.rs, lines 242-245)
If a proposer initiates a vote right before the nightly whitelist rebuild (midnight), the vote is permanently bound to an old whitelist that excludes any voters who registered since the last rebuild. The on-chain VoteMeta root hash is fixed at vote creation; an audit tool (Section 7) could compare it against a freshly-built SMT from all registered DIDs at that block height to identify excluded voters.
4.7 External: Unauthenticated Whitelist Rebuild
Requires: Network access to the API (no authentication needed).
The /api/vote/build_whitelist endpoint is an unauthenticated GET request. The source code comment says “for debugging, don’t call randomly” (Chinese: 方便调试用的,请勿随意调用).
Reproduction:
curl 'https://<host>/api/vote/build_whitelist'
Any unauthenticated client can trigger a full whitelist rebuild at any time. Combined with the stale whitelist issue in Section 4.6, the following sequence is possible:
- Trigger
/api/vote/build_whitelistto create a new whitelist - If the CKB RPC node is temporarily unreachable during the rebuild, the resulting whitelist could be empty or incomplete (individual CKB address resolutions and deposit checks are skipped on failure via
if let Ok(...)atbuild_vote_whitelist.rs:55-56; additionally, the profile query itself swallows database errors via.unwrap_or(vec![])at line 50) - A vote created shortly after would reference this incomplete whitelist
As with Section 4.4, permissive CORS (main.rs:206) makes this endpoint callable cross-origin from any website.
4.8 Insider: Selective Proof Denial
Requires: Backend operator access (can modify server code or intercept requests).
The /api/vote/prepare and /api/vote/proof endpoints are the only way for voters to obtain their SMT proof. Both call get_proof() internally, which takes the voter’s ckb_addr (vote.rs:141), reconstructs the SMT, and returns the proof as Vec<u8> (vote.rs:177). The operator can target individual voters at three points:
- Selective error: Add a condition keyed on
ckb_addrto return the existing"Not in the whitelist"error (vote.rs:187). The targeted voter sees the same message as a legitimately excluded user. - Timing denial: Delay the
/api/vote/prepareresponse (vote.rs:468) until the voting period closes. Period enforcement is off-chain (check_vote_finished.rs:94-100), so a proof delivered after the deadline is useless even though the VoteMeta cell still exists on-chain. - Proof corruption: Modify the proof bytes after the server-side verification (
vote.rs:181-183) but before the HTTP response. The voter receives a proof the server “verified,” but the on-chain SMT check (entry.rs:106-108) rejects it withError::VerifySmtFail.
In all three cases the targeted voter cannot distinguish the denial from a legitimate failure, and the on-chain contract provides no log of rejected proofs. Unlike whitelist curation (Section 4.1), selective proof denial does not alter the on-chain root hash, so a chain audit tool cannot detect it after the fact.
4.9 External: Premature Vote Termination
Requires: Depends on the lock script used on the VoteMeta cell.
The contract documentation (docs/ckb-dao-vote.md, line 63) states: “Once the vote meta cell is consumed in any transaction, the entire vote session is permanently closed and no further votes can be cast.” This is because the vote type script locates the VoteMeta cell via cell_deps (line 61): once consumed, the cell no longer exists in the live set and new vote transactions cannot reference it.
Who can consume the VoteMeta cell is controlled by its lock script, not the vote type script. If the cell is deployed with a weak lock (or if the lock key is compromised), whoever can spend it can terminate an active vote session prematurely, with no way to reopen it. Visible on-chain: an audit tool (Section 7) can check whether the VoteMeta cell was consumed before the vote’s declared end time.
4.10 External: Voter Enumeration via Proof Endpoint
Requires: Network access to the API (no authentication needed).
The /api/vote/whitelist endpoint returns the full member list (all lock hashes) in a single unauthenticated GET request. This is the most direct enumeration vector, though it is affected by the ID format mismatch (Section 5.3). The /api/vote/proof endpoint provides an alternative: it takes a ckb_addr and whitelist_id and returns either a valid proof (voter is on the whitelist) or an error (voter is not), allowing address-by-address enumeration.
Either endpoint leaks:
- Which addresses have staked CKB and registered a Web5 DID
- The exact composition of the voter list for any given vote
- Whether a specific community member can vote
Permissive CORS (main.rs:206) means both endpoints are also callable cross-origin, enabling browser-based enumeration without user interaction. The cross-origin impact on state-mutating endpoints is documented in Sections 4.4 and 4.7.
5. Additional Bugs and Platform Issues
The following bugs and platform issues were identified during the audit. They are not exploitation scenarios but would cause failures in mainnet operation.
5.1 Design: Browser-Only Key Storage
Users lose access to their Web5 account by clearing browser cache. The private signing key is stored unencrypted in localStorage. A documented export/import mechanism exists (register-web5-did.mdx, lines 112-136) protected with a user-set password, but it is presented as optional (“we recommend”) rather than mandatory at account creation, and no server-side recovery is available for users who did not export.
5.2 Bug: 8-Minute Voting Window
The create_vote_tx function (used by initiation_vote) contains test code left in production. Line 254 computes a proper 7-day vote time range via get_vote_time_range(&state.ckb_client, 7), but line 255 immediately shadows it with test_get_vote_time_range() (commented // TODO: for test only, remove it later at ckb.rs:451), which computes a ~50-block window (~8 minutes).
Votes created through initiation_vote would have an ~8-minute voting period instead of the documented 7 days. Since create_vote_meta is broken (Section 5.3), initiation_vote is the only working vote creation path, so all votes would use this window. The VoteMeta cell’s start and end times are committed on-chain, so an audit tool (Section 7) would immediately flag the ~8-minute window against the documented 7-day requirement.
5.3 Bug: Whitelist ID Format Mismatch
The whitelist cron job stores entries with RFC 3339 IDs:
let id = chrono::Local::now().to_rfc3339(); // e.g., "2026-03-15T00:00:00+08:00"
(Source: build_vote_whitelist.rs:87)
But create_vote_meta references whitelists by date-only format:
whitelist_id: chrono::Local::now().format("%Y-%m-%d").to_string(), // e.g., "2026-03-15"
These IDs never match. When build_vote_meta queries the VoteWhitelist table with whitelist_id = "2026-03-15", no row exists (the cron job stored it as "2026-03-15T00:00:00+08:00"), so vote creation fails.
The alternative code path, create_vote_tx (used by initiation_vote), avoids this bug by selecting the most recent whitelist row directly and using its actual id. create_vote_meta is in fact dead code: despite its utoipa annotation (/api/vote/create_vote_meta), no matching .route() call exists in main.rs, and no other function calls it. It is unreachable.
5.4 Bug: Hardcoded Testnet Network Type
All 8 NetworkType references in production code across 4 backend files (ckb.rs lines 17, 90, 235, 320; lib.rs:198; vote.rs lines 165, 440; build_vote_whitelist.rs:64) use ckb_sdk::NetworkType::Testnet. Zero occurrences of NetworkType::Mainnet. A get_network_type() function that queries the RPC for the actual network exists (lib.rs:202-206) but is never called.
The backend is confirmed running against mainnet RPC since March 1 (Section 1.1 timeline, post 114). CKB lock hash computation is network-agnostic, so the system functions because all addresses are generated internally with the same wrong prefix (ckt1q instead of ckb1q). The mismatch is harmless as long as the frontend and backend agree on the convention, but the AddressParser will reject externally-supplied mainnet addresses (ckb1q prefix) and the code cannot be pointed at a different network without a rebuild.
Beyond address parsing, get_vote_result() at ckb.rs:205 hardcodes the vote type script’s code_hash to 0xb140de2d... with hash_type: "type". This is the testnet type_id (confirmed by the frontend at token.ts:70). The mainnet type_id is 0x3871...3afb (from the deployment file). Since the function searches for a type_id that does not exist on mainnet, the /api/vote/detail endpoint returns zero vote cells on mainnet. This does not affect the tally (which reads from the vote indexer, not CKB RPC), but it means the only backend endpoint that reads vote data from the chain is broken on mainnet.
5.5 Bug: DAO Deposit Detection Failures
Existing Nervos DAO deposits may not be recognized after binding. Multiple code-level causes:
- Hardcoded Testnet address parsing:
get_nervos_dao_deposit()atckb.rs:17setsNetworkType::Testnet, rejecting addresses withckb1qprefix - PW Lock detection limited:
pw_lock_capacity()atckb.rs:78only processes PW Lock args starting with"12", returning 0 for other patterns without logging - No indexer synchronization: The binding indexer and the CKB RPC indexer are independent systems. The
get_weight()function (indexer_bind.rs:46-70) queries both but has no guarantee the binding indexer has synced a recent binding transaction
Phroi reported this on the mainnet testbed: Neuron binding succeeded but 400k CKB mainnet deposits were not recognized after 30 minutes (Nervos Talk post 115). An independent chain audit (Section 7) would bypass all three issues by reading Nervos DAO deposits directly from CKB.
5.6 Bug: Wrong Hashes in Contract Docs and Frontend Mainnet Config
The vote type script IS deployed to mainnet (migration file deployment/mainnet/migrations/2026-02-25-122044.json, tx_hash 0xd8cb...3247d, type_id 0x3871...3afb, deployed 2026-02-25), and reproducible build scripts exist (scripts/reproducible_build_docker, deployment/mainnet/verify.sh). However, several artifacts remain incomplete:
- The specification (
docs/ckb-dao-vote.md:271-273) still listscode_hashandtx_hashas “TODO” - The reproducible build instructions section is still “TODO”
- The frontend mainnet config (
token.ts:75-77) has the wrongvoteTypeCodeHash: it uses the deploymenttx_hash(0xd8cb...3247d) instead of thetype_id(0x3871...3afb) from the migration file. The remaining two fields (voteContractTxHash,depGroupTxHash) reuse testnet values with TODO comments (待提供真主网 hash)
6. Path Forward
The deployed code does not match the approved proposal (whitelist retained, Section 3) and contains additional issues documented in Sections 4-5 (unauthenticated governance endpoints, unverified indexer, Sybil weight amplification, test code in production). These need to be acknowledged and addressed before the switchover from V1.0 to V1.1.
Whitelist removal scope
The on-chain contract supports open voting when smt_root_hash is None (entry.rs:88: if root_hash.is_some()). A passing test (test_open_vote at tests/src/tests.rs:26-107) confirms this path works with an empty smt_proof. Setting the backend to pass None would disable whitelisting at the software layer:
- Backend: In
build_vote_meta, setsmt_root_hashtoNoneinstead ofSome(root_hash) - Backend: In
/api/vote/prepare, return an emptysmt_proofinstead of gating on whitelist membership. The endpoint must still return alock_script_hashbecause the contract always loadsVoteProofand reads it (entry.rs:85-86), even in open-vote mode - Frontend: In
buildAndSendVoteTransaction, relax the proof validation guard (line 405) to allow an empty proof array, but still build a VoteProof molecule with a validlock_script_hashand emptysmt_proofbytes. The contract requires a VoteProof in the witness regardless of voting mode - Documentation: Remove whitelist references from
vote.mdx,faq.mdx, andbind-nervosdao-address.mdx
However, these software changes do not remove the whitelisting capability. The contract still accepts smt_root_hash = Some(...), and whoever holds the VoteMeta cell’s lock key can always create one with a whitelist. A backend code change the operator can undo is not a fix to a trust problem with the operator. A multisig lock on the VoteMeta cell would shift trust to a quorum but still leave the capability in the contract.
The fix is to deploy a new contract that removes the if root_hash.is_some() branch entirely, so smt_root_hash is ignored regardless of what the VoteMeta cell contains. The contract already has reproducible build scripts (scripts/reproducible_build_docker), and the change is a single deletion (remove lines 88-108 of entry.rs). The contract is deployed via type_id, so the existing deployment cell can be upgraded in-place without changing the type script hash that the frontend and backend reference.
The daily whitelist cron job and the /api/vote/whitelist, /api/vote/proof, and /api/vote/build_whitelist endpoints become dead code and can be removed in a follow-up.
Chain-reading audit tool
Independently of the whitelist, the system needs a chain-reading vote audit tool. Individual vote cells, Nervos DAO deposits, and binding relationships are all on-chain, but the codebase provides no way to verify a tally against the chain. The backend’s detail endpoint fetches vote cells from CKB, but it runs on the same server being audited. A standalone tool that reads vote cells directly from a CKB node, looks up each voter’s on-chain Nervos DAO deposit, and recomputes the tally would make roughly half the issues in this report visible after the fact: whitelist curation (Section 4.1), unverified indexer trust (Section 4.2), stale whitelist exclusions (Section 4.6), premature vote termination (Section 4.9), the 8-minute voting window (Section 5.2), and partial deposit detection failures (Section 5.5). Weight amplification via Sybil binding (Section 4.3) is a design flaw, not a code defect, but an audit tool would make its effect on any given vote visible.
Detection does not replace fixing. But an audit tool gives the community an independent check they can run without trusting the operator. The documentation claims the process is “open and verifiable” (technical-overview.mdx:159); an audit tool would make that claim true.
7. Discussion
The remediation above is technically straightforward. The harder problem is that the deployed code contradicts the steward team’s public statements (Section 1.2), and seven weeks have passed since the original commitment to remove the whitelist with no visible progress in any repository (Section 3.5).
The whitelist is also not the only concern:
- The vote tally trusts an external indexer with no chain cross-check (Section 4.2)
- Sybil binding amplifies vote weight when the same person votes from two DIDs (Section 4.3)
- The unauthenticated
/api/proposal/update_stateendpoint (Section 4.4) can set any proposal to any state without voting - Fund transfers are manual: the
/api/task/send_fundsendpoint records an admin-supplied transaction hash but does not construct or submit a CKB transaction
The governance process (whitelist, voting, quorum thresholds, admin approval, fund release) runs as advisory software on a centralized backend, with the operator holding every lever documented in Section 2. No component provides an independent check on any other.
The DAO Management Committee (Jan, Terry, Cipher) is responsible for verifying that deployed code matches approved proposals. V1.1 uses Nervos DAO deposits as voting weight, so governance outcomes affect every depositor whether they participate or not.
Based on the evidence in this report:
-
The March 16 launch should not proceed in its current state. The unauthenticated
/api/proposal/update_stateendpoint (Section 4.4) allows anyone on the internet to set any proposal to any lifecycle state. The test code overriding the 7-day vote duration with an ~8-minute window (Section 5.2) means all votes would close before most voters could participate. These are not theoretical risks: they are bugs in deployed code that would produce wrong results on day one. -
The unauthenticated endpoints and the test voting window are launch blockers, independent of the whitelist question. Even if the whitelist were removed today, a system where proposal states can be changed by any unauthenticated HTTP request and votes close in 8 minutes cannot produce legitimate governance outcomes.
Two questions remain for the Management Committee, which this report cannot answer:
- Timeline for whitelist removal
- What independent review is required before funds are placed under DAO V1.1 control
Phroi’s comment on Nervos Talk post 115 (March 7, 2026) has gone unanswered for over a week. The steward team announced the mainnet launch without responding.