diff --git a/librustvoting/Cargo.toml b/librustvoting/Cargo.toml index 502d1e1cd..a2f5337cb 100644 --- a/librustvoting/Cargo.toml +++ b/librustvoting/Cargo.toml @@ -18,7 +18,7 @@ halo2_gadgets = "0.3" halo2_proofs = "0.3" # Serialization -hex = "0.4" +hex = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/librustvoting/src/storage/migrations.rs b/librustvoting/src/storage/migrations.rs index f18e75eae..5a6e67d5e 100644 --- a/librustvoting/src/storage/migrations.rs +++ b/librustvoting/src/storage/migrations.rs @@ -2,7 +2,7 @@ use rusqlite::Connection; use crate::VotingError; -const CURRENT_VERSION: u32 = 5; +const CURRENT_VERSION: u32 = 6; pub fn migrate(conn: &Connection) -> Result<(), VotingError> { let version: u32 = conn @@ -130,6 +130,59 @@ pub fn migrate(conn: &Connection) -> Result<(), VotingError> { })?; } + if version < 6 { + // v6: share-status polling support. + // + // votes: add van_authority_spent (separate from submitted — tracks + // CastVote TX confirmation for proposal_authority bitmask). + // The column may already exist if 001_init.sql was used by a + // drop+recreate step (v3/v4/v5) — check before altering. + // + // share_delegations: PK now includes helper_url (one receipt per + // helper per share); renamed nullifier→share_nullifier, + // confirmed→reveal_confirmed; added seq, submit_at columns. + // SQLite cannot alter PKs so drop+recreate is required. Any + // in-flight v5 share delegations are lost; the polling scanner + // will resubmit them on next app launch. + let has_van_column = conn + .prepare("SELECT van_authority_spent FROM votes LIMIT 0") + .is_ok(); + if !has_van_column { + conn.execute_batch( + "ALTER TABLE votes ADD COLUMN van_authority_spent INTEGER NOT NULL DEFAULT 0;", + ) + .map_err(|e| VotingError::Internal { + message: format!("migration to version 6 failed (alter votes): {}", e), + })?; + } + conn.execute_batch( + "DROP TABLE IF EXISTS share_delegations; + CREATE TABLE share_delegations ( + round_id TEXT NOT NULL, + wallet_id TEXT NOT NULL DEFAULT '', + bundle_index INTEGER NOT NULL, + proposal_id INTEGER NOT NULL, + share_index INTEGER NOT NULL, + helper_url TEXT NOT NULL, + share_nullifier BLOB NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + submit_at INTEGER NOT NULL DEFAULT 0, + reveal_confirmed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (round_id, wallet_id, bundle_index, proposal_id, share_index, helper_url), + FOREIGN KEY (round_id, wallet_id, bundle_index) + REFERENCES bundles(round_id, wallet_id, bundle_index) ON DELETE CASCADE + );", + ) + .map_err(|e| VotingError::Internal { + message: format!("migration to version 6 failed (share_delegations): {}", e), + })?; + conn.pragma_update(None, "user_version", 6) + .map_err(|e| VotingError::Internal { + message: format!("failed to update database version: {}", e), + })?; + } + let final_version: u32 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .map_err(|e| VotingError::Internal { @@ -197,6 +250,99 @@ mod tests { assert!(tables.contains(&"keystone_signatures".to_string())); } + #[test] + fn test_migrate_v5_to_v6() { + let conn = Connection::open_in_memory().unwrap(); + + // Simulate a v5 database by running init then setting version to 5 + // with the old share_delegations schema. + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + conn.execute_batch( + "CREATE TABLE rounds ( + round_id TEXT NOT NULL, wallet_id TEXT NOT NULL DEFAULT '', + snapshot_height INTEGER NOT NULL, ea_pk BLOB NOT NULL, + nc_root BLOB NOT NULL, nullifier_imt_root BLOB NOT NULL, + session_json TEXT, phase INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, PRIMARY KEY (round_id, wallet_id) + ); + CREATE TABLE bundles ( + round_id TEXT NOT NULL, wallet_id TEXT NOT NULL DEFAULT '', + bundle_index INTEGER NOT NULL, note_positions_blob BLOB, + van_comm_rand BLOB, dummy_nullifiers BLOB, rho_signed BLOB, + padded_note_data BLOB, nf_signed BLOB, cmx_new BLOB, + alpha BLOB, rseed_signed BLOB, rseed_output BLOB, + gov_comm BLOB, total_note_value INTEGER, address_index INTEGER, + van_leaf_position INTEGER, rk BLOB, gov_nullifiers_blob BLOB, + padded_note_secrets BLOB, pczt_sighash BLOB, delegation_tx_hash TEXT, + PRIMARY KEY (round_id, wallet_id, bundle_index), + FOREIGN KEY (round_id, wallet_id) REFERENCES rounds(round_id, wallet_id) ON DELETE CASCADE + ); + CREATE TABLE votes ( + id INTEGER PRIMARY KEY, round_id TEXT NOT NULL, + wallet_id TEXT NOT NULL DEFAULT '', bundle_index INTEGER NOT NULL, + proposal_id INTEGER NOT NULL, choice INTEGER NOT NULL, + commitment BLOB, submitted INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, tx_hash TEXT, + vc_tree_position INTEGER, commitment_bundle_json TEXT, + UNIQUE(round_id, wallet_id, bundle_index, proposal_id), + FOREIGN KEY (round_id, wallet_id, bundle_index) + REFERENCES bundles(round_id, wallet_id, bundle_index) ON DELETE CASCADE + ); + CREATE TABLE share_delegations ( + round_id TEXT NOT NULL, wallet_id TEXT NOT NULL DEFAULT '', + bundle_index INTEGER NOT NULL, proposal_id INTEGER NOT NULL, + share_index INTEGER NOT NULL, helper_url TEXT NOT NULL, + nullifier BLOB NOT NULL, confirmed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + PRIMARY KEY (round_id, wallet_id, bundle_index, proposal_id, share_index), + FOREIGN KEY (round_id, wallet_id, bundle_index) + REFERENCES bundles(round_id, wallet_id, bundle_index) ON DELETE CASCADE + );", + ).unwrap(); + conn.pragma_update(None, "user_version", 5).unwrap(); + + // Insert test data so we can verify votes survive the migration. + conn.execute_batch( + "INSERT INTO rounds VALUES ('r1','w1',100,X'00',X'00',X'00',NULL,0,0); + INSERT INTO bundles (round_id,wallet_id,bundle_index) VALUES ('r1','w1',0); + INSERT INTO votes (round_id,wallet_id,bundle_index,proposal_id,choice,submitted,created_at) + VALUES ('r1','w1',0,3,1,1,0); + INSERT INTO share_delegations (round_id,wallet_id,bundle_index,proposal_id,share_index,helper_url,nullifier,confirmed,created_at) + VALUES ('r1','w1',0,3,0,'https://h1',X'AA',1,0);", + ).unwrap(); + + migrate(&conn).unwrap(); + + let version: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!(version, 6); + + // votes table gained van_authority_spent, existing rows default to 0. + let (submitted, van_spent): (i64, i64) = conn + .query_row( + "SELECT submitted, van_authority_spent FROM votes WHERE proposal_id = 3", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(submitted, 1); + assert_eq!(van_spent, 0); + + // share_delegations was recreated — old rows are gone, new schema present. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM share_delegations", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + + // Verify new columns exist by inserting a v6 receipt. + conn.execute( + "INSERT INTO share_delegations (round_id,wallet_id,bundle_index,proposal_id,share_index,helper_url,share_nullifier,seq,created_at,submit_at,reveal_confirmed) + VALUES ('r1','w1',0,3,0,'https://h1',X'BB',1,0,1700000000,0)", + [], + ).unwrap(); + } + /// Verify that the bundles table columns exist after migration and can round-trip BLOB data. #[test] fn test_bundle_data_columns_exist() { diff --git a/librustvoting/src/storage/migrations/001_init.sql b/librustvoting/src/storage/migrations/001_init.sql index a42fd4145..7668d779c 100644 --- a/librustvoting/src/storage/migrations/001_init.sql +++ b/librustvoting/src/storage/migrations/001_init.sql @@ -81,6 +81,7 @@ CREATE TABLE votes ( choice INTEGER NOT NULL, commitment BLOB, submitted INTEGER NOT NULL DEFAULT 0, + van_authority_spent INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, tx_hash TEXT, vc_tree_position INTEGER, @@ -90,16 +91,18 @@ CREATE TABLE votes ( ); CREATE TABLE share_delegations ( - round_id TEXT NOT NULL, - wallet_id TEXT NOT NULL DEFAULT '', - bundle_index INTEGER NOT NULL, - proposal_id INTEGER NOT NULL, - share_index INTEGER NOT NULL, - helper_url TEXT NOT NULL, - nullifier BLOB NOT NULL, - confirmed INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - PRIMARY KEY (round_id, wallet_id, bundle_index, proposal_id, share_index), + round_id TEXT NOT NULL, + wallet_id TEXT NOT NULL DEFAULT '', + bundle_index INTEGER NOT NULL, + proposal_id INTEGER NOT NULL, + share_index INTEGER NOT NULL, + helper_url TEXT NOT NULL, + share_nullifier BLOB NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + submit_at INTEGER NOT NULL DEFAULT 0, + reveal_confirmed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (round_id, wallet_id, bundle_index, proposal_id, share_index, helper_url), FOREIGN KEY (round_id, wallet_id, bundle_index) REFERENCES bundles(round_id, wallet_id, bundle_index) ON DELETE CASCADE ); diff --git a/librustvoting/src/storage/mod.rs b/librustvoting/src/storage/mod.rs index 34b3aa6b6..9180970a1 100644 --- a/librustvoting/src/storage/mod.rs +++ b/librustvoting/src/storage/mod.rs @@ -71,6 +71,33 @@ pub struct KeystoneSignatureRecord { pub rk: Vec, } +/// One helper-targeted share submission receipt (deterministic share nullifier for status polling). +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ShareDelegationReceipt { + pub share_index: u32, + pub helper_url: String, + /// 32-byte share nullifier (JSON: lowercase hex string, matches `hex::serde`). + #[serde(with = "hex::serde")] + pub share_nullifier: Vec, + /// Submission order within `(share_index, bundle, proposal)` for status polling (first target first). + pub seq: u32, + /// Unix seconds when the helper should reveal; 0 = immediate. + #[serde(default)] + pub submit_at: u64, + /// Whether this helper row has observed on-chain confirmation for this nullifier. + #[serde(default)] + pub reveal_confirmed: bool, +} + +/// Pending share-reveal work for one `(round, bundle, proposal)` (votes not yet fully confirmed). +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct PendingShareRevealGroup { + pub round_id: String, + pub bundle_index: u32, + pub proposal_id: u32, + pub receipts: Vec, +} + /// Database handle for voting state. Wraps a SQLite connection and a /// wallet identifier that scopes all round data to a single wallet. pub struct VotingDb { @@ -151,7 +178,7 @@ mod tests { let version: u32 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .unwrap(); - assert_eq!(version, 5); + assert_eq!(version, 6); } #[test] diff --git a/librustvoting/src/storage/operations.rs b/librustvoting/src/storage/operations.rs index 97cf4c904..4e23bb705 100644 --- a/librustvoting/src/storage/operations.rs +++ b/librustvoting/src/storage/operations.rs @@ -1,10 +1,12 @@ use std::collections::HashMap; use ff::PrimeField; +use pasta_curves::pallas; use crate::storage::queries; use crate::storage::{ - KeystoneSignatureRecord, RoundPhase, RoundState, RoundSummary, VoteRecord, VotingDb, + KeystoneSignatureRecord, RoundPhase, RoundState, RoundSummary, ShareDelegationReceipt, + VoteRecord, VotingDb, }; use crate::types::{ DelegationProofResult, DelegationSubmissionData, EncryptedShare, @@ -243,20 +245,22 @@ impl VotingDb { ) -> Result { let total_start = std::time::Instant::now(); - // Phase 1: DB queries + // Phase 1: DB queries — acquire and release the connection before the + // long-running PIR fetch and proof generation so other callers (e.g. + // checkPendingShareReveals) aren't blocked for the entire proof duration. let db_start = std::time::Instant::now(); - let conn = self.conn(); let wallet_id = self.wallet_id(); - let params = queries::load_round_params(&conn, round_id, &wallet_id)?; - let alpha = queries::load_alpha(&conn, round_id, &wallet_id, bundle_index)?; - let van_comm_rand = queries::load_van_comm_rand(&conn, round_id, &wallet_id, bundle_index)?; - let witnesses = queries::load_witnesses(&conn, round_id, &wallet_id, bundle_index)?; - - // Load Phase 1 randomness for ZCA-74 fix: ensures Phase 2 produces - // the same nf_signed/cmx_new that Phase 1 committed to in the PCZT. - let rseed_signed = queries::load_rseed_signed(&conn, round_id, &wallet_id, bundle_index)?; - let rseed_output = queries::load_rseed_output(&conn, round_id, &wallet_id, bundle_index)?; - let padded_secrets = queries::load_padded_note_secrets(&conn, round_id, &wallet_id, bundle_index)?; + let (params, alpha, van_comm_rand, witnesses, rseed_signed, rseed_output, padded_secrets) = { + let conn = self.conn(); + let p = queries::load_round_params(&conn, round_id, &wallet_id)?; + let a = queries::load_alpha(&conn, round_id, &wallet_id, bundle_index)?; + let v = queries::load_van_comm_rand(&conn, round_id, &wallet_id, bundle_index)?; + let w = queries::load_witnesses(&conn, round_id, &wallet_id, bundle_index)?; + let rs = queries::load_rseed_signed(&conn, round_id, &wallet_id, bundle_index)?; + let ro = queries::load_rseed_output(&conn, round_id, &wallet_id, bundle_index)?; + let ps = queries::load_padded_note_secrets(&conn, round_id, &wallet_id, bundle_index)?; + (p, a, v, w, rs, ro, ps) + }; // MutexGuard dropped here // Align witnesses (keyed by commitment) to notes order let witness_count = witnesses.len(); @@ -360,6 +364,41 @@ impl VotingDb { imt_proofs.len() ); + // The delegation circuit's public `nf_imt_root` is taken from each PIR proof. + // CheckTx verifies the proof against `round.nullifier_imt_root` from + // `MsgCreateVotingSession`. If the app points at a different PIR deployment than + // the round creator, proving still succeeds locally but the chain rejects with + // "invalid zero-knowledge proof" — fail fast with an explicit error instead. + let expected_nf_imt: [u8; 32] = params + .nullifier_imt_root + .as_slice() + .try_into() + .map_err(|_| VotingError::Internal { + message: format!( + "stored nullifier_imt_root must be exactly 32 bytes, got {}", + params.nullifier_imt_root.len() + ), + })?; + let expected_nf_imt_fe = Option::from(pallas::Base::from_repr(expected_nf_imt)).ok_or_else(|| { + VotingError::Internal { + message: "stored nullifier_imt_root is not a canonical Pallas field element" + .to_string(), + } + })?; + for (i, proof) in imt_proofs.iter().enumerate() { + if proof.root != expected_nf_imt_fe { + return Err(VotingError::Internal { + message: format!( + "PIR nullifier IMT root does not match this voting round (note {i}). \ + actual_from_pir={} expected_from_round={}. \ + Use the same nullifier / PIR server URL as when the round was created, or recreate the round.", + hex::encode(proof.root.to_repr()), + hex::encode(expected_nf_imt_fe.to_repr()), + ), + }); + } + } + // Phase 3: Proof generation let prove_start = std::time::Instant::now(); eprintln!("[ZKP1] Starting proof generation..."); @@ -420,22 +459,22 @@ impl VotingDb { prove_elapsed.as_secs_f64() ); - // Store proof bytes for debugging/recovery - queries::store_proof(&conn, round_id, &wallet_id, bundle_index, &result.proof)?; - // Persist prover's public inputs — needed later for delegation TX submission. - // With PrecomputedRandomness (ZCA-74 fix), nf_signed/cmx_new should match - // Phase 1 values. We still store them to be explicit and support the legacy path. - queries::store_proof_result_fields( - &conn, - round_id, - &wallet_id, - bundle_index, - &result.rk, - &result.gov_nullifiers, - &result.nf_signed, - &result.cmx_new, - )?; - queries::update_round_phase(&conn, round_id, &wallet_id, RoundPhase::DelegationProved)?; + // Re-acquire the DB connection to persist results. + { + let conn = self.conn(); + queries::store_proof(&conn, round_id, &wallet_id, bundle_index, &result.proof)?; + queries::store_proof_result_fields( + &conn, + round_id, + &wallet_id, + bundle_index, + &result.rk, + &result.gov_nullifiers, + &result.nf_signed, + &result.cmx_new, + )?; + queries::update_round_phase(&conn, round_id, &wallet_id, RoundPhase::DelegationProved)?; + } let total_elapsed = total_start.elapsed(); eprintln!( @@ -717,7 +756,7 @@ impl VotingDb { queries::delete_bundles_from(&conn, round_id, &wallet_id, keep_count) } - /// Mark a vote as submitted to the vote chain. + /// Mark a vote as submitted (share reveals confirmed). pub fn mark_vote_submitted( &self, round_id: &str, @@ -729,6 +768,18 @@ impl VotingDb { queries::mark_vote_submitted(&conn, round_id, &wallet_id, bundle_index, proposal_id) } + /// Mark a proposal's VAN authority bit as spent for this bundle. + pub fn mark_van_authority_spent( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::mark_van_authority_spent(&conn, round_id, &wallet_id, bundle_index, proposal_id) + } + // --- Recovery state --- pub fn store_delegation_tx_hash( @@ -826,11 +877,94 @@ impl VotingDb { let wallet_id = self.wallet_id(); queries::clear_recovery_state(&conn, round_id, &wallet_id) } + + pub fn store_share_delegation_receipt( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + receipt: &ShareDelegationReceipt, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::store_share_delegation_receipt( + &conn, + round_id, + &wallet_id, + bundle_index, + proposal_id, + receipt, + ) + } + + pub fn list_share_delegation_receipts( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::list_share_delegation_receipts( + &conn, + round_id, + &wallet_id, + bundle_index, + proposal_id, + ) + } + + pub fn clear_share_delegation_receipts_for_vote( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::clear_share_delegation_receipts_for_vote( + &conn, + round_id, + &wallet_id, + bundle_index, + proposal_id, + ) + } + + pub fn mark_share_revealed_for_helper( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + share_index: u32, + helper_url: &str, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::mark_share_revealed_for_helper( + &conn, + round_id, + &wallet_id, + bundle_index, + proposal_id, + share_index, + helper_url, + ) + } + + pub fn list_pending_share_reveal_groups( + &self, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::list_pending_share_reveal_groups(&conn, &wallet_id) + } } #[cfg(test)] mod tests { use super::*; + use crate::storage::ShareDelegationReceipt; // 64 hex chars = 32 bytes when decoded. Required because build_governance_pczt // hex-decodes vote_round_id and validates it as exactly 32 bytes (a Pallas field element). @@ -866,6 +1000,106 @@ mod tests { assert_eq!(state.snapshot_height, 1000); } + #[test] + fn test_share_delegation_receipt_roundtrip() { + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + let conn = db.conn(); + queries::insert_bundle(&conn, ROUND_ID, W, 0, &[]).unwrap(); + drop(conn); + + let r = ShareDelegationReceipt { + share_index: 2, + helper_url: "https://helper.example".to_string(), + share_nullifier: vec![0xde; 32], + seq: 1, + submit_at: 1700000000, + reveal_confirmed: false, + }; + db.store_share_delegation_receipt(ROUND_ID, 0, 7, &r).unwrap(); + let rows = db.list_share_delegation_receipts(ROUND_ID, 0, 7).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].share_index, 2); + assert_eq!(rows[0].seq, 1); + assert_eq!(rows[0].helper_url, "https://helper.example"); + assert_eq!(rows[0].share_nullifier, vec![0xde; 32]); + + db.clear_share_delegation_receipts_for_vote(ROUND_ID, 0, 7).unwrap(); + assert!(db + .list_share_delegation_receipts(ROUND_ID, 0, 7) + .unwrap() + .is_empty()); + } + + #[test] + fn test_pending_share_reveal_groups_lifecycle() { + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + let conn = db.conn(); + queries::insert_bundle(&conn, ROUND_ID, W, 0, &[]).unwrap(); + let commitment = vec![0xCC; 128]; + queries::store_vote(&conn, ROUND_ID, W, 0, 3, 1, &commitment).unwrap(); + queries::store_vote(&conn, ROUND_ID, W, 0, 5, 0, &commitment).unwrap(); + drop(conn); + + // No receipts yet — nothing pending. + assert!(db.list_pending_share_reveal_groups().unwrap().is_empty()); + + // Insert receipts for proposal 3: two shares, each sent to two helpers. + for (si, nf_byte) in [(0u32, 0xA0u8), (1, 0xA1)] { + for (seq, url) in [(0u32, "https://h1"), (1, "https://h2")] { + db.store_share_delegation_receipt(ROUND_ID, 0, 3, &ShareDelegationReceipt { + share_index: si, + helper_url: url.to_string(), + share_nullifier: vec![nf_byte; 32], + seq, + submit_at: 1700000000, + reveal_confirmed: false, + }).unwrap(); + } + } + // Insert receipts for proposal 5: one share, one helper. + db.store_share_delegation_receipt(ROUND_ID, 0, 5, &ShareDelegationReceipt { + share_index: 0, + helper_url: "https://h1".to_string(), + share_nullifier: vec![0xB0; 32], + seq: 0, + submit_at: 0, + reveal_confirmed: false, + }).unwrap(); + + // Both proposals appear as pending groups. + let groups = db.list_pending_share_reveal_groups().unwrap(); + assert_eq!(groups.len(), 2); + let g3 = groups.iter().find(|g| g.proposal_id == 3).unwrap(); + let g5 = groups.iter().find(|g| g.proposal_id == 5).unwrap(); + assert_eq!(g3.receipts.len(), 4); // 2 shares × 2 helpers + assert_eq!(g5.receipts.len(), 1); + + // Confirm share 0 on helper h1 for proposal 3. + db.mark_share_revealed_for_helper(ROUND_ID, 0, 3, 0, "https://h1").unwrap(); + let groups = db.list_pending_share_reveal_groups().unwrap(); + let g3 = groups.iter().find(|g| g.proposal_id == 3).unwrap(); + assert_eq!(g3.receipts.len(), 3); // one fewer pending receipt + + // Confirm all remaining receipts for proposal 3. + db.mark_share_revealed_for_helper(ROUND_ID, 0, 3, 0, "https://h2").unwrap(); + db.mark_share_revealed_for_helper(ROUND_ID, 0, 3, 1, "https://h1").unwrap(); + db.mark_share_revealed_for_helper(ROUND_ID, 0, 3, 1, "https://h2").unwrap(); + + // Proposal 3 still appears because votes.submitted = 0. + // Mark it submitted — now it should vanish. + db.mark_vote_submitted(ROUND_ID, 0, 3).unwrap(); + let groups = db.list_pending_share_reveal_groups().unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].proposal_id, 5); + + // Confirm and submit proposal 5 — list should be empty. + db.mark_share_revealed_for_helper(ROUND_ID, 0, 5, 0, "https://h1").unwrap(); + db.mark_vote_submitted(ROUND_ID, 0, 5).unwrap(); + assert!(db.list_pending_share_reveal_groups().unwrap().is_empty()); + } + #[test] fn test_list_and_clear_rounds() { let db = test_db(); @@ -1097,7 +1331,14 @@ mod tests { .submitted ); - // Verify proposal_authority reflects per-bundle submission state + // proposal_authority uses van_authority_spent, not submitted + let conn = db.conn(); + let zkp2_0 = queries::load_zkp2_inputs(&conn, ROUND_ID, W, 0).unwrap(); + assert_eq!(zkp2_0.proposal_authority, 0xFFFF); // submitted ≠ van_authority_spent + + // Mark van_authority_spent and verify proposal_authority clears the bit + drop(conn); + db.mark_van_authority_spent(ROUND_ID, 0, 0).unwrap(); let conn = db.conn(); let zkp2_0 = queries::load_zkp2_inputs(&conn, ROUND_ID, W, 0).unwrap(); assert_eq!(zkp2_0.proposal_authority, 0xFFFF & !(1u64 << 0)); // bit 0 cleared diff --git a/librustvoting/src/storage/queries.rs b/librustvoting/src/storage/queries.rs index 48897c73f..d5cca3dea 100644 --- a/librustvoting/src/storage/queries.rs +++ b/librustvoting/src/storage/queries.rs @@ -1,7 +1,8 @@ -use rusqlite::{named_params, Connection}; +use rusqlite::{named_params, params, Connection}; use crate::storage::{ - KeystoneSignatureRecord, RoundPhase, RoundState, RoundSummary, VoteRecord, + KeystoneSignatureRecord, PendingShareRevealGroup, RoundPhase, RoundState, RoundSummary, + ShareDelegationReceipt, VoteRecord, }; use crate::types::{VotingError, VotingRoundParams}; @@ -575,8 +576,8 @@ pub struct Zkp2DelegationData { const MAX_PROPOSAL_AUTHORITY: u64 = 65535; /// Load all fields ZKP #2 needs from the bundles table (persisted during delegation). -/// Computes proposal_authority from submitted votes — each submitted vote clears its -/// proposal's bit, so the next vote's VAN reconstruction matches what's in the VC tree. +/// Computes proposal_authority from votes whose VAN authority was already spent, +/// so the next vote's VAN reconstruction matches what's in the VC tree. pub fn load_zkp2_inputs( conn: &Connection, round_id: &str, @@ -603,11 +604,11 @@ pub fn load_zkp2_inputs( message: format!("failed to load ZKP2 inputs for round={}, bundle={} ({})", round_id, bundle_index, e), })?; - // Compute current proposal_authority by clearing bits for already-submitted votes - // for THIS bundle specifically. + // Compute current proposal_authority by clearing bits for proposals whose VAN + // authority has been spent (ZKP #2 completed and CastVote TX confirmed). let mut authority = MAX_PROPOSAL_AUTHORITY; let mut stmt = conn - .prepare("SELECT proposal_id FROM votes WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index AND submitted = 1") + .prepare("SELECT proposal_id FROM votes WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index AND van_authority_spent = 1") .map_err(|e| VotingError::Internal { message: format!("failed to prepare proposal_authority query: {}", e), })?; @@ -990,8 +991,12 @@ pub fn store_vote( .as_secs() as i64; conn.execute( - "INSERT OR REPLACE INTO votes (round_id, wallet_id, bundle_index, proposal_id, choice, commitment, submitted, created_at) - VALUES (:round_id, :wallet_id, :bundle_index, :proposal_id, :choice, :commitment, 0, :created_at)", + "INSERT INTO votes (round_id, wallet_id, bundle_index, proposal_id, choice, commitment, submitted, created_at) + VALUES (:round_id, :wallet_id, :bundle_index, :proposal_id, :choice, :commitment, 0, :created_at) + ON CONFLICT(round_id, wallet_id, bundle_index, proposal_id) DO UPDATE SET + choice = excluded.choice, + commitment = excluded.commitment, + created_at = excluded.created_at", named_params! { ":round_id": round_id, ":wallet_id": wallet_id, @@ -1068,7 +1073,7 @@ pub fn mark_vote_submitted( bundle_index: u32, proposal_id: u32, ) -> Result<(), VotingError> { - conn.execute( + let rows = conn.execute( "UPDATE votes SET submitted = 1 WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index AND proposal_id = :proposal_id", named_params! { ":round_id": round_id, @@ -1080,6 +1085,49 @@ pub fn mark_vote_submitted( .map_err(|e| VotingError::Internal { message: format!("failed to mark vote submitted: {}", e), })?; + if rows == 0 { + return Err(VotingError::Internal { + message: format!( + "vote not found: round={}, bundle={}, proposal={}", + round_id, bundle_index, proposal_id + ), + }); + } + Ok(()) +} + +/// Mark a proposal's VAN authority bit as spent for this bundle. This +/// clears the bit in the `proposal_authority` bitmask computed by +/// `load_zkp2_inputs`, so the next proposal reconstructs the updated VAN +/// (with the decremented bitmask). Separate from `submitted` which tracks +/// share-reveal completion. +pub fn mark_van_authority_spent( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, +) -> Result<(), VotingError> { + let rows = conn.execute( + "UPDATE votes SET van_authority_spent = 1 WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index AND proposal_id = :proposal_id", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + ":proposal_id": proposal_id as i64, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to mark van_authority_spent: {}", e), + })?; + if rows == 0 { + return Err(VotingError::Internal { + message: format!( + "vote not found: round={}, bundle={}, proposal={}", + round_id, bundle_index, proposal_id + ), + }); + } Ok(()) } @@ -1296,6 +1344,238 @@ pub fn get_keystone_signatures( }) } +// --- Share delegation receipts (per-helper share nullifiers) --- + +pub fn store_share_delegation_receipt( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, + receipt: &ShareDelegationReceipt, +) -> Result<(), VotingError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + conn.execute( + "INSERT INTO share_delegations (round_id, wallet_id, bundle_index, proposal_id, share_index, helper_url, share_nullifier, seq, created_at, submit_at, reveal_confirmed) + VALUES (:round_id, :wallet_id, :bundle_index, :proposal_id, :share_index, :helper_url, :share_nullifier, :seq, :created_at, :submit_at, :reveal_confirmed) + ON CONFLICT(round_id, wallet_id, bundle_index, proposal_id, share_index, helper_url) + DO UPDATE SET share_nullifier = excluded.share_nullifier, seq = excluded.seq, created_at = excluded.created_at, + submit_at = excluded.submit_at, reveal_confirmed = excluded.reveal_confirmed", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + ":proposal_id": proposal_id as i64, + ":share_index": receipt.share_index as i64, + ":helper_url": receipt.helper_url, + ":share_nullifier": receipt.share_nullifier.as_slice(), + ":seq": receipt.seq as i64, + ":created_at": now, + ":submit_at": receipt.submit_at as i64, + ":reveal_confirmed": if receipt.reveal_confirmed { 1i64 } else { 0i64 }, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to store share delegation receipt: {}", e), + })?; + Ok(()) +} + +pub fn list_share_delegation_receipts( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, +) -> Result, VotingError> { + let mut stmt = conn + .prepare( + "SELECT share_index, helper_url, share_nullifier, seq, submit_at, reveal_confirmed FROM share_delegations + WHERE round_id = ?1 AND wallet_id = ?2 AND bundle_index = ?3 AND proposal_id = ?4 + ORDER BY share_index ASC, seq ASC, helper_url ASC", + ) + .map_err(|e| VotingError::Internal { + message: format!("prepare list share_delegation_receipts: {}", e), + })?; + + let rows = stmt + .query_map( + params![round_id, wallet_id, bundle_index as i64, proposal_id as i64], + |r| { + Ok(ShareDelegationReceipt { + share_index: r.get::<_, i64>(0)? as u32, + helper_url: r.get(1)?, + share_nullifier: r.get(2)?, + seq: r.get::<_, i64>(3)? as u32, + submit_at: r.get::<_, i64>(4)? as u64, + reveal_confirmed: r.get::<_, i64>(5)? != 0, + }) + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("query share_delegation_receipts: {}", e), + })?; + + rows.collect::, _>>() + .map_err(|e| VotingError::Internal { + message: format!("read share_delegation_receipt row: {}", e), + }) +} + +pub fn mark_share_revealed_for_helper( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, + share_index: u32, + helper_url: &str, +) -> Result<(), VotingError> { + conn.execute( + "UPDATE share_delegations SET reveal_confirmed = 1 + WHERE round_id = :round_id AND wallet_id = :wallet_id + AND bundle_index = :bundle_index AND proposal_id = :proposal_id + AND share_index = :share_index AND helper_url = :helper_url", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + ":proposal_id": proposal_id as i64, + ":share_index": share_index as i64, + ":helper_url": helper_url, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to mark share revealed: {}", e), + })?; + Ok(()) +} + +/// Groups with at least one `reveal_confirmed = 0` row and matching vote `submitted = 0`. +/// `submitted` tracks share-reveal completion (separate from `van_authority_spent` which +/// tracks CastVote TX acceptance for proposal_authority bookkeeping). +pub fn list_pending_share_reveal_groups( + conn: &Connection, + wallet_id: &str, +) -> Result, VotingError> { + let mut stmt = conn + .prepare( + "SELECT DISTINCT s.round_id, s.bundle_index, s.proposal_id + FROM share_delegations s + INNER JOIN votes v + ON v.round_id = s.round_id AND v.wallet_id = s.wallet_id + AND v.bundle_index = s.bundle_index AND v.proposal_id = s.proposal_id + WHERE s.wallet_id = :wallet_id + AND s.reveal_confirmed = 0 + AND v.submitted = 0 + ORDER BY s.round_id, s.bundle_index, s.proposal_id", + ) + .map_err(|e| VotingError::Internal { + message: format!("prepare list_pending_share_reveal_groups: {}", e), + })?; + + let keys: Vec<(String, i64, i64)> = stmt + .query_map(named_params! { ":wallet_id": wallet_id }, |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .map_err(|e| VotingError::Internal { + message: format!("query pending share reveal keys: {}", e), + })? + .collect::, _>>() + .map_err(|e| VotingError::Internal { + message: format!("read pending share reveal key row: {}", e), + })?; + + let mut out = Vec::with_capacity(keys.len()); + for (round_id, bundle_index, proposal_id) in keys { + let receipts = list_pending_receipts_for_vote( + conn, + &round_id, + wallet_id, + bundle_index as u32, + proposal_id as u32, + )?; + if !receipts.is_empty() { + out.push(PendingShareRevealGroup { + round_id, + bundle_index: bundle_index as u32, + proposal_id: proposal_id as u32, + receipts, + }); + } + } + Ok(out) +} + +fn list_pending_receipts_for_vote( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, +) -> Result, VotingError> { + let mut stmt = conn + .prepare( + "SELECT share_index, helper_url, share_nullifier, seq, submit_at, reveal_confirmed + FROM share_delegations + WHERE round_id = ?1 AND wallet_id = ?2 AND bundle_index = ?3 AND proposal_id = ?4 + AND reveal_confirmed = 0 + ORDER BY share_index ASC, seq ASC, helper_url ASC", + ) + .map_err(|e| VotingError::Internal { + message: format!("prepare list_pending_receipts_for_vote: {}", e), + })?; + + let rows = stmt + .query_map( + params![round_id, wallet_id, bundle_index as i64, proposal_id as i64], + |r| { + Ok(ShareDelegationReceipt { + share_index: r.get::<_, i64>(0)? as u32, + helper_url: r.get(1)?, + share_nullifier: r.get(2)?, + seq: r.get::<_, i64>(3)? as u32, + submit_at: r.get::<_, i64>(4)? as u64, + reveal_confirmed: r.get::<_, i64>(5)? != 0, + }) + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("query pending receipts: {}", e), + })?; + + rows.collect::, _>>() + .map_err(|e| VotingError::Internal { + message: format!("read pending receipt row: {}", e), + }) +} + +pub fn clear_share_delegation_receipts_for_vote( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, +) -> Result<(), VotingError> { + conn.execute( + "DELETE FROM share_delegations WHERE round_id = :round_id AND wallet_id = :wallet_id + AND bundle_index = :bundle_index AND proposal_id = :proposal_id", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + ":proposal_id": proposal_id as i64, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to clear share delegation receipts: {}", e), + })?; + Ok(()) +} + // --- Recovery state cleanup --- pub fn clear_recovery_state( diff --git a/librustvoting/src/types.rs b/librustvoting/src/types.rs index 27b0447cc..e17a25183 100644 --- a/librustvoting/src/types.rs +++ b/librustvoting/src/types.rs @@ -241,6 +241,9 @@ pub struct SharePayload { /// Blind factor for this specific share (32 bytes, LE pallas::Base repr). /// Only the revealed share's blind is needed for ZKP #3. pub primary_blind: Vec, + /// Deterministic share nullifier (32 bytes, LE `pallas::Base` repr). + /// Used for helper share-status polling without an opaque server token. + pub share_nullifier: Vec, } /// Computed signature fields for cast-vote TX submission. diff --git a/librustvoting/src/vote_commitment.rs b/librustvoting/src/vote_commitment.rs index c56a150e0..7c4f9f7ab 100644 --- a/librustvoting/src/vote_commitment.rs +++ b/librustvoting/src/vote_commitment.rs @@ -2,6 +2,73 @@ use crate::types::{ validate_encrypted_shares, validate_vote_decision, CastVoteSignature, WireEncryptedShare, SharePayload, VoteCommitmentBundle, VotingError, }; +use ff::PrimeField; +use pasta_curves::pallas; +use vote_commitment_tree::vote_commitment_hash; +use voting_circuits::share_reveal::share_nullifier_hash; + +/// Parse 32 bytes as canonical little-endian `pallas::Base` (same as circuit public inputs). +fn fp_from_repr32(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(VotingError::Internal { + message: format!("field repr must be 32 bytes, got {}", bytes.len()), + }); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(bytes); + Option::from(pallas::Base::from_repr(arr)).ok_or_else(|| VotingError::Internal { + message: "invalid pallas::Base representation".to_string(), + }) +} + +/// Decode vote round id hex (optional `0x`) to 32-byte canonical field repr (zero-padded). +fn round_id_bytes_from_hex(round_id_hex: &str) -> Result<[u8; 32], VotingError> { + let s = round_id_hex.trim().strip_prefix("0x").unwrap_or(round_id_hex.trim()); + let raw = hex::decode(s).map_err(|e| VotingError::Internal { + message: format!("invalid vote_round_id hex: {e}"), + })?; + if raw.len() > 32 { + return Err(VotingError::Internal { + message: format!( + "vote_round_id hex decodes to {} bytes, max is 32", + raw.len() + ), + }); + } + let mut out = [0u8; 32]; + out[..raw.len()].copy_from_slice(&raw); + Ok(out) +} + +/// Compute the share nullifier (32-byte LE `pallas::Base` repr) for share-status polling. +/// +/// Matches the circuit: `vote_commitment_hash` then `share_nullifier_hash` with the same +/// domain tag as ZKP #3 (`voting_circuits::share_reveal`). +#[must_use] +pub fn compute_share_nullifier( + round_id_hex: &str, + shares_hash: &[u8], + proposal_id: u32, + vote_decision: u32, + share_index: u32, + primary_blind: &[u8], +) -> Result, VotingError> { + let rid = round_id_bytes_from_hex(round_id_hex)?; + let round_id_fp = fp_from_repr32(&rid)?; + let shares_hash_fp = fp_from_repr32(shares_hash)?; + let proposal_id_fp = pallas::Base::from(u64::from(proposal_id)); + let vote_decision_fp = pallas::Base::from(u64::from(vote_decision)); + let vc = vote_commitment_hash( + round_id_fp, + shares_hash_fp, + proposal_id_fp, + vote_decision_fp, + ); + let share_index_fp = pallas::Base::from(u64::from(share_index)); + let blind_fp = fp_from_repr32(primary_blind)?; + let nf = share_nullifier_hash(vc, share_index_fp, blind_fp); + Ok(nf.to_repr().to_vec()) +} /// Build payloads for helper server (one per share). /// @@ -39,9 +106,19 @@ pub fn build_share_payloads( let mut payloads = Vec::with_capacity(iter_shares.len()); for (i, share) in iter_shares.iter().enumerate() { - let primary_blind = commitment.share_blinds.get(i) + let primary_blind = commitment + .share_blinds + .get(i) .cloned() .unwrap_or_default(); + let share_nullifier = compute_share_nullifier( + &commitment.vote_round_id, + &commitment.shares_hash, + commitment.proposal_id, + vote_decision, + share.share_index, + &primary_blind, + )?; payloads.push(SharePayload { shares_hash: commitment.shares_hash.clone(), proposal_id: commitment.proposal_id, @@ -51,6 +128,7 @@ pub fn build_share_payloads( all_enc_shares: all_enc_shares.clone(), share_comms: commitment.share_comms.clone(), primary_blind, + share_nullifier, }); } @@ -186,9 +264,11 @@ mod tests { proof: vec![0xAB; 256], enc_shares: vec![], anchor_height: 0, - vote_round_id: String::new(), - shares_hash: vec![0xDD; 32], - share_blinds: (0..5).map(|_| vec![0x11; 32]).collect(), + // 32-byte hex so `compute_share_nullifier` parses round id like production. + vote_round_id: "00".repeat(32), + // Canonical zero field reprs so `compute_share_nullifier` accepts mock data. + shares_hash: vec![0u8; 32], + share_blinds: (0..5).map(|_| vec![0u8; 32]).collect(), share_comms: (0..5).map(|_| vec![0x22; 32]).collect(), r_vpk_bytes: vec![0xEE; 32], alpha_v: vec![0xFF; 32], @@ -206,5 +286,8 @@ mod tests { assert_eq!(result[0].shares_hash, commitment.shares_hash); assert_eq!(result[0].enc_share.share_index, 0); assert_eq!(result[1].enc_share.share_index, 1); + assert_eq!(result[0].share_nullifier.len(), 32); + assert_eq!(result[1].share_nullifier.len(), 32); + assert_ne!(result[0].share_nullifier, result[1].share_nullifier); } } diff --git a/librustvoting/src/zkp2.rs b/librustvoting/src/zkp2.rs index 2962cdd3d..53bfa1036 100644 --- a/librustvoting/src/zkp2.rs +++ b/librustvoting/src/zkp2.rs @@ -143,7 +143,6 @@ pub fn build_vote_commitment( ea_pk_affine, alpha_v, proposal_authority, - single_share, ) .map_err(|e| VotingError::ProofFailed { message: format!("vote proof generation failed: {}", e),