DAO V1.1 Whitelist and Beyond: Community-Led Code Review

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 (--whitelist CLI 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:

  1. Iterates all registered Web5 DID entries in the profile table
  2. For each DID, resolves to a CKB address
  3. Checks Nervos DAO deposit amount via get_nervos_dao_deposit()
  4. Only includes addresses with deposit > 0
  5. Builds an SMT from all qualifying lock hashes
  6. 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 administrator table, 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_id to 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 sets smt_root_hash to Some(...) (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_type field 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_hash actually 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:

  1. Wait for the nightly whitelist cron job to run
  2. Directly UPDATE the vote_whitelist table to add or remove addresses
  3. Any subsequent vote creation references the modified whitelist
  4. The on-chain VoteMeta cell now contains the SMT root hash of the modified list
  5. Removed voters call /api/vote/prepare and 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:

  1. Trigger /api/vote/build_whitelist to create a new whitelist
  2. 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(...) at build_vote_whitelist.rs:55-56; additionally, the profile query itself swallows database errors via .unwrap_or(vec![]) at line 50)
  3. 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:

  1. Selective error: Add a condition keyed on ckb_addr to return the existing "Not in the whitelist" error (vote.rs:187). The targeted voter sees the same message as a legitimately excluded user.
  2. Timing denial: Delay the /api/vote/prepare response (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.
  3. 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 with Error::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() at ckb.rs:17 sets NetworkType::Testnet, rejecting addresses with ckb1q prefix
  • PW Lock detection limited: pw_lock_capacity() at ckb.rs:78 only 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 lists code_hash and tx_hash as “TODO”
  • The reproducible build instructions section is still “TODO”
  • The frontend mainnet config (token.ts:75-77) has the wrong voteTypeCodeHash: it uses the deployment tx_hash (0xd8cb...3247d) instead of the type_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:

  1. Backend: In build_vote_meta, set smt_root_hash to None instead of Some(root_hash)
  2. Backend: In /api/vote/prepare, return an empty smt_proof instead of gating on whitelist membership. The endpoint must still return a lock_script_hash because the contract always loads VoteProof and reads it (entry.rs:85-86), even in open-vote mode
  3. Frontend: In buildAndSendVoteTransaction, relax the proof validation guard (line 405) to allow an empty proof array, but still build a VoteProof molecule with a valid lock_script_hash and empty smt_proof bytes. The contract requires a VoteProof in the witness regardless of voting mode
  4. Documentation: Remove whitelist references from vote.mdx, faq.mdx, and bind-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_state endpoint (Section 4.4) can set any proposal to any state without voting
  • Fund transfers are manual: the /api/task/send_funds endpoint 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:

  1. The March 16 launch should not proceed in its current state. The unauthenticated /api/proposal/update_state endpoint (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.

  2. 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.

12 Likes

Hi Phroi, thanks for calling out these issues. I noticed that you have replied to David’s comment on the previous post. I copied it here for others to reference.

First of all, I apologize for misunderstanding your question months ago; that was not intentional. I didn’t know there were two whitelists back then. Nonetheless, that’s my fault, so I’m sorry for the confusion I caused.

@david-fi5box answered part of the questions:

Regarding the other questions, they have been relayed to the dev team, and the team is actively working on them. We will post a proposed path forward to this post as soon as possible.

Again, thanks for your detailed review and the continued attention to building a trustworthy community governance mechanism.

4 Likes

Thanks to Phroi for sharing the code review report. Since the promise to remove the whitelist stemmed from a misunderstanding, I won’t repeat those points here. Please see my replies in repost above.

On the overall technical approach, we follow CKB’s philosophy “Don’t trust, just verify”.

Due to CKB’s cell model, a fully on-chain voting system is nearly impossible

So our goal isn’t trustless, it’s verifiability.

Below are point-by-point responses to the specific technical issues raised:

3.1 The contract supports empty smt_proof to allow anyone to vote because the vote contract was designed as a general-purpose contract. In the CCFDAO context, only non-empty smt_proof is supported. VoteMeta is created by the proposer, but CCFDAO verifies that the smt_proof value matches the platform’s records—independent audit tools can easily detect discrepancies.

4.1 audit tool can detect it

4.2 audit tool can detect it

4.3 CCFDAO has checks in place for Sybil attacks, but we’ll re-verify the specific scenarios listed in the report.

4.4 for test, has removed

4.5 for test, has removed

4.6 for test, has removed

4.7 for test, has removed

4.8 audit tool can detect it

4.9 audit tool can detect it

4.10 audit tool detect 4.8 need it, so audit tool can rebuild merkel tree

5.1 Users can set a new key via the CKB wallet associated with their DID.

5.2 for test, has removed

5.3 fixed

5.4 for test, has removed

5.5 todo fix

5.6 fixed

Finally, the audit tool is a critical part of this project, but due to scheduling constraints, this work was deprioritized. We’ll expedite its development. Community contributions are also welcome to ensure its independence.

2 Likes

“The whitelist issue seems more like a mechanism design problem to me, not a technical issue, as it seems not to be an incorrect implementation, but designed to so. I’m curious how was the dao v1.1 mechanism design decisions formed?”

“In short, I see a possible communication gap where design questions are being addressed by implementers rather than designers.”

Jan Xie, Nervos co-founder and DAO committee member

The dev team’s response demonstrates exactly this gap: it explains how the whitelist works (“general-purpose contract”, “audit tool can detect”), not why it should exist. The following checks whether the technical claims themselves hold up.

Summary

Section Issue Severity Dev Claim Verdict Evidence
3.1 On-chain SMT whitelist enforcement Critical General-purpose contract; audit tool detects NOT FIXED Contract unchanged
3.2 Backend whitelist generation Critical (none) NOT FIXED build_vote_whitelist.rs cron unchanged
3.3 Frontend SMT proof requirement Critical (none) NOT FIXED votingUtils.ts:405 proof guard unchanged
3.4 Documentation whitelist references Medium (none) NOT FIXED Docs repo unchanged
3.5 No branch removes whitelist Critical (none) NOT FIXED No whitelist-removal commits in any repo
4.1 Administrator curates voter list Critical audit tool can detect NOT FIXED Whitelist cron + DB architecture unchanged
4.2 Unverified indexer trust Critical audit tool can detect NOT FIXED Indexer unchanged
4.3 Sybil weight amplification High Has checks in place NOT FIXED No Sybil checks found in any repo
4.4 Unauthenticated update_state Critical for test, has removed FIXED Endpoint removed from proposal.rs and main.rs routes
4.5 DID-level proposal whitelist High for test, has removed FIXED --whitelist arg removed; record.rs deleted
4.6 Stale whitelist via timing Medium for test, has removed FIXED build_whitelist endpoint removed; daily staleness is accepted design
4.7 Unauthenticated build_whitelist Medium for test, has removed FIXED Endpoint removed from main.rs routes
4.8 Selective proof denial High audit tool can detect NOT FIXED Proof endpoints unchanged
4.9 Premature vote termination High audit tool can detect NOT FIXED Contract unchanged
4.10 Voter enumeration via proof endpoint Info audit tool needs it FIXED Intentionally kept for audit tooling; data already on-chain
5.1 Browser-only key storage Medium CKB wallet key reset NOT FIXED No key recovery mechanism in docs or code
5.2 8-minute voting window Critical for test, has removed FIXED test_get_vote_time_range() removed; real duration used
5.3 Whitelist ID format mismatch Medium fixed NOT FIXED RFC 3339 vs %Y-%m-%d mismatch persists
5.4 Hardcoded testnet NetworkType High for test, has removed FIXED Configurable via --ckb_net; mainnet hashes present
5.5 DAO deposit detection failures Medium todo fix PARTIALLY FIXED Dynamic net type; pw_lock_capacity "12" filter unchanged
5.6 Wrong hashes in frontend config Medium fixed FIXED All mainnet hashes corrected in token.ts

Totals: 8 FIXED, 1 PARTIALLY FIXED, 12 NOT FIXED.

Note on the “audit tool” defense

Sections 3.1, 4.1, 4.2, 4.8, and 4.9 share the same dev response: “audit tool can detect it.” No such tool exists in any examined repo. Section 4.8 (selective proof denial) is undetectable even in principle: the denial is an off-chain HTTP non-response that leaves no on-chain trace, so no audit tool, however complete, could distinguish a denied voter from one who chose not to vote.

Repository Changes

Two of seven repos changed. Each was compared at its pinned SHA (from the original review) against current HEAD.

Repository (GitHub) Fork name Pinned SHA Current HEAD Changed?
CCF-DAO1-1/ccfdao-v1.1-docs ccfdao-v11-docs 810ba726 810ba726 No
CCF-DAO1-1/ckb-dao-vote ckb-dao-vote cc0d7d0b cc0d7d0b No
CCF-DAO1-1/ckb-fund-dao-ui ccfdao-v11-ui 04083983 558888bb Yes
CCF-DAO1-1/app_view ccfdao-v11-app-view 6ebfddbe 479ef1d4 Yes
web5fans/ckb-dao-vote web5fans-ckb-dao-vote 4554d310 4554d310 No
web5fans/web5-indexer web5fans-indexer 83ec7c80 83ec7c80 No
web5fans/dao_deploy web5fans-dao-deploy 95a8a1e6 95a8a1e6 No

Changes in ccfdao-v11-app-view (backend)

5 commits: refactor ckb net type, update mainnet code hash, clean for release, update vote_time_range, clean. 13 files changed (-771 / +122 lines). Major removals: src/api/record.rs (entire file), src/relayer/stream.rs (test module), unauthenticated update_state and build_whitelist endpoints, test_get_vote_time_range(). Added: --ckb_net CLI arg with runtime NetworkType selection and mainnet code hashes.

Changes in ccfdao-v11-ui (frontend)

~180 commits across dev branch merged to main. 19 files changed in the diff from pinned SHA to HEAD (-539 / +914 lines). Major changes: mainnet token/voting hashes corrected in token.ts, depGroupTxHash field removed, token refresh logic replaced with immediate logout, account creation flow reordered, debug logging added, votingUtils.ts.bak backup file committed.

Remaining Issues

Section numbers match the original report for cross-reference. Sections not listed here (3.5, 4.4-4.7, 4.10, 5.2, 5.4, 5.6) were verified as fixed in the summary table above.

3.1 On-Chain SMT Whitelist Enforcement

Original finding: The vote type script at entry.rs:88 enforces SMT proof verification when smt_root_hash is Some(...). The backend unconditionally sets it to Some(...).

Dev claim: “The contract supports empty smt_proof to allow anyone to vote because the vote contract was designed as a general-purpose contract. In the CCFDAO context, only non-empty smt_proof is supported.”

Current code: Contract unchanged. The if root_hash.is_some() branch at entry.rs:88-108 is intact. The contract documentation at ckb-dao-vote.md:34-35 still reads: “When set: Only users included in the SMT can vote (restricted vote). When None: All users can vote (open vote).”

Verdict: NOT FIXED. The dev’s response acknowledges the design: the contract is intentionally general-purpose. In the CCFDAO deployment, the backend always populates smt_root_hash, making the whitelist mandatory for every vote. The detection the dev describes is feasible given public chain data (see note on the “audit tool” defense), but no such tool exists. The original finding stands.

3.2 Backend Whitelist Generation

Original finding: A daily cron job in build_vote_whitelist.rs builds an SMT from registered Web5 DIDs with Nervos DAO deposits.

Current code: build_vote_whitelist.rs:40-99 is unchanged. The cron expression "0 0 0 * * *" (midnight) is unchanged. The function still: iterates DID profiles, resolves CKB addresses, checks deposits, builds SMT, stores root hash. Eligibility still requires both a Web5 DID and a DAO deposit.

Verdict: NOT FIXED. Original finding stands.

3.3 Frontend SMT Proof Requirement

Original finding: votingUtils.ts requires a valid SMT proof from the backend. Line 405 rejects votes without proof.

Current code: votingUtils.ts:405-407:

if (!voteData.proof || !Array.isArray(voteData.proof)) {
  throw new Error(getT('modal.voting.errors.missingProof', t));
}

The proof validation guard is unchanged. The buildAndSendVoteTransaction function still constructs a VoteProof molecule from the backend-provided proof and embeds it in the witness outputType. The core vote flow is unchanged; the diff from pinned SHA to HEAD does not alter the vote flow.

Verdict: NOT FIXED. Original finding stands.

3.4 Documentation Whitelist References

Original finding: Documentation at faq.mdx:75, vote.mdx:140, and bind-nervosdao-address.mdx:138 describes the whitelist as mandatory.

Current code: Docs repo unchanged.

  • faq.mdx:75: “the system collects the Owner addresses corresponding to all currently registered Web5 DID accounts as the voting whitelist. Only Web5 DID accounts on the whitelist can participate in voting”
  • vote.mdx:140: “smt_proof: Cryptographic proof that this lock_script_hash is in the authorized voter whitelist”
  • bind-nervosdao-address.mdx:138: “After binding an address, it will only be included in the whitelist when the next proposal initiates voting”

Verdict: NOT FIXED. All whitelist references intact.

4.1 Insider: Administrator Curates the Voter List

Original finding: Anyone with database access can modify the vote_whitelist table and alter the voter list.

Dev claim: “audit tool can detect it”

Current code: Backend whitelist architecture unchanged. VoteWhitelist.insert uses ON CONFLICT ... UPDATE (unchanged). Both the cron job and create_vote_tx read from the vote_whitelist table. The /api/vote/whitelist endpoint that would allow community verification is broken by the ID format mismatch (Section 5.3, still unfixed).

Verdict: NOT FIXED. The dev defers to the non-existent audit tool (see note on the “audit tool” defense). The community verification endpoint (/api/vote/whitelist) remains broken.

4.2 Insider: Vote Tally Trusts an Unverified Indexer

Original finding: The tally function calls all_votes() via unauthenticated HTTP to the vote indexer.

Dev claim: “audit tool can detect it”

Current code: Indexer unchanged. The all_votes() call path in the backend is unchanged. The indexer’s DISTINCT ON(address) deduplication remains the sole voter-dedup mechanism.

Verdict: NOT FIXED. The dev defers to the non-existent audit tool (see note on the “audit tool” defense).

4.3 Design: Vote Weight Amplification via Sybil Binding

Original finding: One person with two DIDs can amplify vote weight through unilateral binding. This is the same class of attack that affected the Community Fund DAO v1.0 vote, where a single actor cast multiple votes to influence the outcome.

Dev claim: “CCFDAO has checks in place for Sybil attacks, but we’ll re-verify the specific scenarios listed in the report.”

Current code: The get_weight() function in indexer_bind.rs is unchanged. The indexer repo (web5fans-indexer) has no Sybil prevention: the DID registration handler at ckb.rs:240-309 records every DID cell on-chain with no uniqueness constraint on ckbAddress. The did_record schema has primary key (did), not (ckbAddress). The query_all_did_doc_by_ckb_addr function explicitly returns a Vec<String> of multiple DIDs per address. No code matching “sybil” or “duplicate” exists in any repo. No cross-voter deduplication exists in the tally loop.

Verdict: NOT FIXED. The claim that “CCFDAO has checks in place” is not substantiated by any code in any repo examined.

4.8 Insider: Selective Proof Denial

Original finding: The operator controls the only proof distribution path via /api/vote/prepare and /api/vote/proof.

Dev claim: “audit tool can detect it”

Current code: Both endpoints are unchanged. prepare at vote.rs:357-390 takes a PrepareBody (not SignedBody), calls get_proof() which reconstructs the SMT from the stored whitelist, and returns the proof bytes. No authentication on either endpoint. The operator can still deny proofs to specific voters.

Verdict: NOT FIXED. The dev defers to the non-existent audit tool (see note on the “audit tool” defense). Selective proof denial cannot be detected after the fact by a chain audit tool, since it does not alter the on-chain root hash.

4.9 External: Premature Vote Termination

Original finding: Consuming the VoteMeta cell closes the vote session permanently.

Dev claim: “audit tool can detect it”

Current code: Contract unchanged. The VoteMeta cell model is identical. Lock script governance is unchanged.

Verdict: NOT FIXED. Visible on-chain (see note on the “audit tool” defense).

5.1 Design: Browser-Only Key Storage

Original finding: Private signing key stored unencrypted in localStorage. Export/import is optional.

Dev claim: “Users can set a new key via the CKB wallet associated with their DID.”

Current code: The docs repo is unchanged. Searched all docs for “key rotation”, “new key”, “reset key”, “replace key”, “update key”: zero results. The only key-related docs are the export/import flow at register-web5-did.mdx:112-136, which is unchanged. The frontend at storage.ts:56 stores the signing key as a plaintext hex string in localStorage under the key @dao:client. In the new createAccount.ts:392, this signing key is also passed as the password field to PDS account creation, so the cryptographic signing material doubles as the PDS authentication credential.

Verdict: NOT FIXED. The dev’s claim of CKB-wallet-based key recovery has no evidence in any repo or documentation.

5.3 Bug: Whitelist ID Format Mismatch

Original finding: Cron job stores RFC 3339 IDs; create_vote_meta queries by %Y-%m-%d.

Dev claim: “fixed”

Current code: The mismatch persists:

  • build_vote_whitelist.rs:89: let id = chrono::Local::now().to_rfc3339(); (e.g. "2026-03-16T00:00:00+08:00")
  • vote.rs:98: let id = chrono::Local::now().format("%Y-%m-%d").to_string(); (e.g. "2026-03-16")

These IDs never match. The dead create_vote_meta function was removed, but the /api/vote/whitelist endpoint at vote.rs:97-112 still queries by the %Y-%m-%d format, meaning it cannot find rows stored with RFC 3339 IDs. The working create_vote_tx function at mod.rs:237-240 avoids the mismatch by selecting the most recent row by ORDER BY created DESC, bypassing the ID entirely.

Verdict: NOT FIXED. The dev removed the dead create_vote_meta function, but the same format mismatch affects the live /api/vote/whitelist endpoint used for community verification (Section 4.1). Community members calling /api/vote/whitelist receive a database error, not the whitelist.

N1: SQL Injection via Expr::cust(format!(...))

The following vulnerability was found during the verification. It predates the five commits examined above and was not covered by the original report or the dev team’s response.

Severity: Critical
File: proposal.rs:93, reply.rs:74, task.rs:81-84, task.rs:132-135, lexicon/proposal.rs:217

N1.1 Root cause

Five locations use Expr::cust(format!(...)) to interpolate user-supplied strings directly into SQL fragments, bypassing sea-query’s parameterized query system. Example from proposal.rs:93:

Expr::cust(format!("record #>> '{{data,title}}' like '%{q}%' ..."))

Verified against the library source (sea-query 1.0.0-rc):

  • Expr::cust() wraps its argument into Expr::Custom(s) with no processing.
  • The query builder prepare_simple_expr renders it as sql.write_str(s): zero escaping, zero parameterization, spliced verbatim into the SQL text.
  • The sea-query-sqlx bridge only handles parameterized Value bindings; it does not touch the SQL string itself.

No layer in the stack sanitizes Expr::Custom content. sea-query provides a safe alternative (Expr::cust_with_values) that uses protocol-level parameter bindings, but the app uses format!() interpolation instead.

sqlx uses the PostgreSQL extended query protocol (prepared statements), so semicolon-based multi-statement attacks (e.g. ; DROP TABLE) are rejected at the protocol level. The injection is confined to single-statement operations: WHERE-clause bypass, subquery-based data extraction, and denial of service.

N1.2 WHERE bypass

The q parameter (from POST /api/proposal/list) and viewer parameter (from GET /api/proposal/detail) are publicly accessible without authentication. The did parameter (from GET /api/task) is also unauthenticated. A crafted value containing a single quote breaks out of the string literal. Example using the q parameter at proposal.rs:93:

POST /api/proposal/list
Content-Type: application/json

{"q": "' OR '1'='1' --"}

The format!() macro produces:

record #>> '{data,title}' like '%' OR '1'='1' --%' or ...

The ' closes the LIKE string, OR '1'='1' makes the condition always true, and -- comments out the rest. All proposals are returned regardless of content.

N1.3 Data extraction

The error handler at error.rs:31-35 returns PostgreSQL error messages directly in the HTTP response body. A CAST subquery forces a type error whose message contains the extracted value:

POST /api/proposal/list
Content-Type: application/json

{"q": "' AND 1=CAST((SELECT did FROM administrator LIMIT 1) AS int) --"}

PostgreSQL rejects the cast and the error is returned to the caller:

{"code": 500, "error": "ServerError",
 "message": "exec sql failed: error returned from database: invalid input syntax for type integer: \"did:web5:abc123...\""}

The admin DID appears in the error message. Incrementing OFFSET extracts every row. The same technique reads vote_whitelist (voter lock hashes and SMT root hashes), proposal (fund receiver_addr addresses), and profile (user data). No authentication is required for any of these.

This compounds with Section 5.1: admin signing keys are stored as plaintext hex in browser localStorage (storage.ts:56) and double as PDS passwords (createAccount.ts:392). An attacker who identifies admin DIDs via this injection has a target list for key theft; obtaining the key grants full admin access, including fund disbursement via send_funds.

N1.4 Denial of service

The connection pool has 5 slots (main.rs:66). pg_sleep is available to all PostgreSQL users by default:

POST /api/proposal/list
Content-Type: application/json

{"q": "' AND pg_sleep(10) IS NOT NULL --"}

Five concurrent requests each hold a connection sleeping for 10 seconds; the pool is exhausted and every other database-dependent endpoint queues and times out. The HTTP timeout (main.rs:193-196, 10 seconds) eventually drops the connection, so sustained DoS requires repeated requests, but with 5 connections and no authentication the effort is trivial. No statement_timeout is configured on the PostgreSQL side.

Verdict: NOT FIXED. Pre-existing in the codebase and not addressed by the 5 new commits.

Recommendations

Combining the unresolved original findings with the new SQL injection: until the audit tool exists, the backend operator retains unilateral control over who can vote, which votes are counted, and what the result is, with no independent verification path available to the community.

  1. Fix the SQL injection immediately (N1). The five Expr::cust(format!(...)) sites allow unauthenticated full read access to the database, including admin DIDs, voter lock hashes, and fund receiver addresses. Replace with Expr::cust_with_values or equivalent parameterized queries.
  2. Fix the ID format mismatch (5.3). The only community verification endpoint (/api/vote/whitelist) is broken: it queries by %Y-%m-%d but the cron stores RFC 3339 IDs.
  3. Add Sybil deduplication (4.3). One person can create multiple DIDs, bind the same CKB address to each, and vote multiple times with full weight. This is the same class of vulnerability that was exploited in the Community Fund DAO v1.0 vote. Add a uniqueness constraint on ckbAddress in the whitelist build or deduplicate by deposit source in the tally.
  4. Publish the audit tool or a specification so the community can build one independently. Without it, none of the Section 4 centralization risks are independently verifiable.
  5. Remove the whitelist at all layers (contract, backend, frontend) so that the system matches the “general-purpose” framing in the dev response.
  6. Commission an independent security audit. N1 was found accidentally in a community-led review, not a security audit. A critical SQL injection hiding in plain sight suggests more vulnerabilities are waiting. The DAO should commission a professional security audit covering the full stack (contract, backend, indexer, frontend, infrastructure) before mainnet deployment.
4 Likes

Man, I wish my words could be quoted under the title “another independent dao v1.1 observer.”

I hope the DAO v1.1 committee reviews feedback solely for its community rootty and makes decisions independently.

3 Likes