Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion librustvoting/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
148 changes: 147 additions & 1 deletion librustvoting/src/storage/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
23 changes: 13 additions & 10 deletions librustvoting/src/storage/migrations/001_init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
);
Expand Down
29 changes: 28 additions & 1 deletion librustvoting/src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,33 @@ pub struct KeystoneSignatureRecord {
pub rk: Vec<u8>,
}

/// 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<u8>,
/// 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<ShareDelegationReceipt>,
}

/// 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 {
Expand Down Expand Up @@ -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]
Expand Down
Loading