diff --git a/librustvoting/src/storage/migrations.rs b/librustvoting/src/storage/migrations.rs index ef3e5d02..f18e75ea 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 = 4; +const CURRENT_VERSION: u32 = 5; pub fn migrate(conn: &Connection) -> Result<(), VotingError> { let version: u32 = conn @@ -55,7 +55,9 @@ pub fn migrate(conn: &Connection) -> Result<(), VotingError> { // v3: delegation data moved from rounds to bundles table, witnesses // gained bundle_index. Drop everything and recreate from 001_init.sql. conn.execute_batch( - "DROP TABLE IF EXISTS votes; + "DROP TABLE IF EXISTS share_delegations; + DROP TABLE IF EXISTS keystone_signatures; + DROP TABLE IF EXISTS votes; DROP TABLE IF EXISTS witnesses; DROP TABLE IF EXISTS proofs; DROP TABLE IF EXISTS bundles; @@ -79,7 +81,9 @@ pub fn migrate(conn: &Connection) -> Result<(), VotingError> { // v4: add wallet_id column for per-wallet state isolation. // Drop everything and recreate from 001_init.sql. conn.execute_batch( - "DROP TABLE IF EXISTS votes; + "DROP TABLE IF EXISTS share_delegations; + DROP TABLE IF EXISTS keystone_signatures; + DROP TABLE IF EXISTS votes; DROP TABLE IF EXISTS witnesses; DROP TABLE IF EXISTS proofs; DROP TABLE IF EXISTS bundles; @@ -99,6 +103,33 @@ pub fn migrate(conn: &Connection) -> Result<(), VotingError> { })?; } + if version < 5 { + // v5: add share_delegations, keystone_signatures tables; add columns to + // bundles (delegation_tx_hash) and votes (tx_hash, vc_tree_position, + // commitment_bundle_json). Drop-all-recreate for pre-production. + conn.execute_batch( + "DROP TABLE IF EXISTS share_delegations; + DROP TABLE IF EXISTS keystone_signatures; + DROP TABLE IF EXISTS votes; + DROP TABLE IF EXISTS witnesses; + DROP TABLE IF EXISTS proofs; + DROP TABLE IF EXISTS bundles; + DROP TABLE IF EXISTS cached_tree_state; + DROP TABLE IF EXISTS rounds;" + ) + .map_err(|e| VotingError::Internal { + message: format!("migration to version 5 failed (drop): {}", e), + })?; + conn.execute_batch(include_str!("migrations/001_init.sql")) + .map_err(|e| VotingError::Internal { + message: format!("migration to version 5 failed (create): {}", e), + })?; + conn.pragma_update(None, "user_version", 5) + .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 { @@ -162,6 +193,8 @@ mod tests { assert!(tables.contains(&"cached_tree_state".to_string())); assert!(tables.contains(&"proofs".to_string())); assert!(tables.contains(&"votes".to_string())); + assert!(tables.contains(&"share_delegations".to_string())); + assert!(tables.contains(&"keystone_signatures".to_string())); } /// Verify that the bundles table columns exist after migration and can round-trip BLOB data. diff --git a/librustvoting/src/storage/migrations/001_init.sql b/librustvoting/src/storage/migrations/001_init.sql index e3e63157..a42fd414 100644 --- a/librustvoting/src/storage/migrations/001_init.sql +++ b/librustvoting/src/storage/migrations/001_init.sql @@ -33,6 +33,7 @@ CREATE TABLE bundles ( 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 ); @@ -81,6 +82,37 @@ CREATE TABLE votes ( 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 +); + +CREATE TABLE keystone_signatures ( + round_id TEXT NOT NULL, + wallet_id TEXT NOT NULL DEFAULT '', + bundle_index INTEGER NOT NULL, + sig BLOB NOT NULL, + sighash BLOB NOT NULL, + rk BLOB NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (round_id, wallet_id, bundle_index), + 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 a6ea04d5..34b3aa6b 100644 --- a/librustvoting/src/storage/mod.rs +++ b/librustvoting/src/storage/mod.rs @@ -62,6 +62,15 @@ pub struct RoundSummary { pub created_at: u64, } +/// A Keystone bundle signature stored in the DB. +#[derive(Clone, Debug)] +pub struct KeystoneSignatureRecord { + pub bundle_index: u32, + pub sig: Vec, + pub sighash: Vec, + pub rk: 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 { @@ -142,7 +151,7 @@ mod tests { let version: u32 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .unwrap(); - assert_eq!(version, 4); + assert_eq!(version, 5); } #[test] diff --git a/librustvoting/src/storage/operations.rs b/librustvoting/src/storage/operations.rs index a95d7d97..65814626 100644 --- a/librustvoting/src/storage/operations.rs +++ b/librustvoting/src/storage/operations.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use ff::PrimeField; use crate::storage::queries; -use crate::storage::{RoundPhase, RoundState, RoundSummary, VoteRecord, VotingDb}; +use crate::storage::{ + KeystoneSignatureRecord, RoundPhase, RoundState, RoundSummary, VoteRecord, VotingDb, +}; use crate::types::{ DelegationProofResult, DelegationSubmissionData, EncryptedShare, GovernancePczt, NoteInfo, ProofProgressReporter, SharePayload, VoteCommitmentBundle, @@ -722,6 +724,104 @@ impl VotingDb { let wallet_id = self.wallet_id(); queries::mark_vote_submitted(&conn, round_id, &wallet_id, bundle_index, proposal_id) } + + // --- Recovery state --- + + pub fn store_delegation_tx_hash( + &self, + round_id: &str, + bundle_index: u32, + tx_hash: &str, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::store_delegation_tx_hash(&conn, round_id, &wallet_id, bundle_index, tx_hash) + } + + pub fn get_delegation_tx_hash( + &self, + round_id: &str, + bundle_index: u32, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::get_delegation_tx_hash(&conn, round_id, &wallet_id, bundle_index) + } + + pub fn store_vote_tx_hash( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + tx_hash: &str, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::store_vote_tx_hash(&conn, round_id, &wallet_id, bundle_index, proposal_id, tx_hash) + } + + pub fn get_vote_tx_hash( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::get_vote_tx_hash(&conn, round_id, &wallet_id, bundle_index, proposal_id) + } + + pub fn store_commitment_bundle( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + bundle_json: &str, + vc_tree_position: u64, + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::store_commitment_bundle(&conn, round_id, &wallet_id, bundle_index, proposal_id, bundle_json, vc_tree_position) + } + + pub fn get_commitment_bundle( + &self, + round_id: &str, + bundle_index: u32, + proposal_id: u32, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::get_commitment_bundle(&conn, round_id, &wallet_id, bundle_index, proposal_id) + } + + pub fn store_keystone_signature( + &self, + round_id: &str, + bundle_index: u32, + sig: &[u8], + sighash: &[u8], + rk: &[u8], + ) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::store_keystone_signature(&conn, round_id, &wallet_id, bundle_index, sig, sighash, rk) + } + + pub fn get_keystone_signatures( + &self, + round_id: &str, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::get_keystone_signatures(&conn, round_id, &wallet_id) + } + + pub fn clear_recovery_state(&self, round_id: &str) -> Result<(), VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + queries::clear_recovery_state(&conn, round_id, &wallet_id) + } } #[cfg(test)] diff --git a/librustvoting/src/storage/queries.rs b/librustvoting/src/storage/queries.rs index cab09f5d..48897c73 100644 --- a/librustvoting/src/storage/queries.rs +++ b/librustvoting/src/storage/queries.rs @@ -1,6 +1,8 @@ use rusqlite::{named_params, Connection}; -use crate::storage::{RoundPhase, RoundState, RoundSummary, VoteRecord}; +use crate::storage::{ + KeystoneSignatureRecord, RoundPhase, RoundState, RoundSummary, VoteRecord, +}; use crate::types::{VotingError, VotingRoundParams}; // --- Rounds --- @@ -1080,3 +1082,254 @@ pub fn mark_vote_submitted( })?; Ok(()) } + +// --- Recovery state: TX hashes --- + +pub fn store_delegation_tx_hash( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + tx_hash: &str, +) -> Result<(), VotingError> { + conn.execute( + "UPDATE bundles SET delegation_tx_hash = :tx_hash WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index", + named_params! { + ":tx_hash": tx_hash, + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to store delegation tx hash: {}", e), + })?; + Ok(()) +} + +pub fn get_delegation_tx_hash( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, +) -> Result, VotingError> { + conn.query_row( + "SELECT delegation_tx_hash FROM bundles WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + }, + |row| row.get(0), + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to get delegation tx hash: {}", e), + }) +} + +pub fn store_vote_tx_hash( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, + tx_hash: &str, +) -> Result<(), VotingError> { + conn.execute( + "UPDATE votes SET tx_hash = :tx_hash WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index AND proposal_id = :proposal_id", + named_params! { + ":tx_hash": tx_hash, + ":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 store vote tx hash: {}", e), + })?; + Ok(()) +} + +pub fn get_vote_tx_hash( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, +) -> Result, VotingError> { + conn.query_row( + "SELECT tx_hash FROM votes 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, + }, + |row| row.get(0), + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to get vote tx hash: {}", e), + }) +} + +// --- Recovery state: commitment bundles --- + +pub fn store_commitment_bundle( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, + bundle_json: &str, + vc_tree_position: u64, +) -> Result<(), VotingError> { + conn.execute( + "UPDATE votes SET commitment_bundle_json = :json, vc_tree_position = :pos WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index AND proposal_id = :proposal_id", + named_params! { + ":json": bundle_json, + ":pos": vc_tree_position as i64, + ":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 store commitment bundle: {}", e), + })?; + Ok(()) +} + +pub fn get_commitment_bundle( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + proposal_id: u32, +) -> Result, VotingError> { + let result = conn.query_row( + "SELECT commitment_bundle_json, vc_tree_position FROM votes 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, + }, + |row| { + let json: Option = row.get(0)?; + let pos: Option = row.get(1)?; + Ok(json.map(|j| (j, pos.unwrap_or(0) as u64))) + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to get commitment bundle: {}", e), + })?; + Ok(result) +} + +// --- Keystone signatures --- + +pub fn store_keystone_signature( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + sig: &[u8], + sighash: &[u8], + rk: &[u8], +) -> Result<(), VotingError> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + conn.execute( + "INSERT OR REPLACE INTO keystone_signatures (round_id, wallet_id, bundle_index, sig, sighash, rk, created_at) VALUES (:round_id, :wallet_id, :bundle_index, :sig, :sighash, :rk, :created_at)", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + ":sig": sig, + ":sighash": sighash, + ":rk": rk, + ":created_at": now as i64, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to store keystone signature: {}", e), + })?; + Ok(()) +} + +pub fn get_keystone_signatures( + conn: &Connection, + round_id: &str, + wallet_id: &str, +) -> Result, VotingError> { + let mut stmt = conn + .prepare( + "SELECT bundle_index, sig, sighash, rk FROM keystone_signatures WHERE round_id = :round_id AND wallet_id = :wallet_id ORDER BY bundle_index", + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to prepare get_keystone_signatures: {}", e), + })?; + + let rows = stmt + .query_map( + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + |row| { + Ok(KeystoneSignatureRecord { + bundle_index: row.get::<_, i64>(0)? as u32, + sig: row.get(1)?, + sighash: row.get(2)?, + rk: row.get(3)?, + }) + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to query keystone signatures: {}", e), + })?; + + rows.collect::, _>>() + .map_err(|e| VotingError::Internal { + message: format!("failed to read keystone signature row: {}", e), + }) +} + +// --- Recovery state cleanup --- + +pub fn clear_recovery_state( + conn: &Connection, + round_id: &str, + wallet_id: &str, +) -> Result<(), VotingError> { + conn.execute( + "DELETE FROM share_delegations WHERE round_id = :round_id AND wallet_id = :wallet_id", + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to clear share delegations: {}", e), + })?; + conn.execute( + "DELETE FROM keystone_signatures WHERE round_id = :round_id AND wallet_id = :wallet_id", + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to clear keystone signatures: {}", e), + })?; + conn.execute( + "UPDATE bundles SET delegation_tx_hash = NULL WHERE round_id = :round_id AND wallet_id = :wallet_id", + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to clear delegation tx hashes: {}", e), + })?; + conn.execute( + "UPDATE votes SET tx_hash = NULL, vc_tree_position = NULL, commitment_bundle_json = NULL WHERE round_id = :round_id AND wallet_id = :wallet_id", + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to clear vote recovery columns: {}", e), + })?; + Ok(()) +}