diff --git a/crates/evm/src/ciphernode_registry_sol.rs b/crates/evm/src/ciphernode_registry_sol.rs index 3448590ce9..a841371cff 100644 --- a/crates/evm/src/ciphernode_registry_sol.rs +++ b/crates/evm/src/ciphernode_registry_sol.rs @@ -462,22 +462,27 @@ pub async fn finalize_committee_on_registry { const options: PollOption[] = [ { value: 0, - votes: poll.option_1_tally, + votes: poll.tally[0] ?? 0, label: poll.option_1_emoji, checked: false, }, { value: 1, - votes: poll.option_2_tally, + votes: poll.tally[1] ?? 0, label: poll.option_2_emoji, checked: false, }, diff --git a/examples/CRISP/crates/crisp-utils/src/lib.rs b/examples/CRISP/crates/crisp-utils/src/lib.rs index 9a1ce7a4f7..6931f3dc84 100644 --- a/examples/CRISP/crates/crisp-utils/src/lib.rs +++ b/examples/CRISP/crates/crisp-utils/src/lib.rs @@ -8,6 +8,9 @@ use e3_bfv_client::decode_bytes_to_vec_u64; use eyre::Result; use num_bigint::BigUint; +/// Maximum number of bits that can fit each vote option. +pub const MAX_VOTE_BITS: usize = 50; + /// Represents decoded vote counts from a tally #[derive(Debug, Clone)] pub struct VoteCounts { @@ -15,43 +18,118 @@ pub struct VoteCounts { pub no: BigUint, } -/// Decode an encoded tally into its decimal representation. +/// Decode an FHE-encrypted tally result into vote counts for each choice. +/// +/// # Encoding scheme +/// +/// The BFV plaintext polynomial has `degree` coefficients (e.g., 512). +/// When encoding a vote with `n` choices, the polynomial is divided into +/// `n` equal segments of `floor(degree / n)` coefficients each. +/// +/// Each segment represents one choice's vote count in binary (big-endian): +/// +/// |---- segment 0 ----|---- segment 1 ----|---- segment 2 ----|-- padding --| +/// | choice 0 (Yes) | choice 1 (No) | choice 2 (Abst) | zeros | +/// | binary, MSB first| binary, MSB first | binary, MSB first| | +/// +/// Within each segment, the binary representation is right-aligned: +/// [0, 0, 0, ..., 0, 1, 0, 1, 1] ← represents decimal 11 +/// ^-- leading zeros ^-- MSB ^-- LSB +/// +/// Because FHE addition is performed coefficient-wise on the polynomial, +/// summing N encrypted votes produces the total count per coefficient. +/// The binary reconstruction then recovers the final tally per choice. +/// +/// # MAX_VOTE_BITS /// -/// The plaintext output from FHE computation contains the result as bytes. -/// Votes are encoded with yes votes in the first half and no votes in the second half, -/// right-aligned with leading zeros. +/// To prevent overflow during FHE computation, only the last `MAX_VOTE_BITS` +/// coefficients of each segment are used (MAX_VOTE_BITS = 50). This caps the +/// maximum representable vote count at `2^50 - 1` (~1.1 quadrillion). +/// +/// We read from the right side of each segment (the significant bits) +/// and ignore leading zeros on the left. /// /// # Arguments /// -/// * `tally_bytes` - The encoded tally as bytes (little-endian format of u64s) +/// * `tally_bytes` - Raw bytes from the FHE decryption, encoding u64 values +/// in little-endian format (8 bytes per coefficient). +/// * `num_choices` - Number of voting options (must match what was used to encode). /// /// # Returns /// -/// A `VoteCounts` struct containing the decoded yes and no vote counts -pub fn decode_tally(tally_bytes: &[u8]) -> Result { - // Decode bytes to numbers array (little-endian, 8 bytes per value) - let decoded = decode_bytes_to_vec_u64(tally_bytes)?; - - // Votes are right-aligned with leading zeros, so we can use the entire halves - let half_d = decoded.len() / 2; - let yes_binary = &decoded[0..half_d]; - let no_binary = &decoded[half_d..decoded.len()]; - - // Convert yes votes (entire first half) - let mut yes = BigUint::from(0u64); - for (i, &value) in yes_binary.iter().enumerate() { - let weight = BigUint::from(2u64).pow((yes_binary.len() - 1 - i) as u32); - yes += BigUint::from(value) * weight; +/// A `Vec` of length `num_choices`, where `results[i]` is the +/// total vote weight for choice `i`. +/// +/// # Example +/// +/// Given degree=512, MAX_VOTE_BITS=50, num_choices=2: +/// segment_size = 512 / 2 = 256 coefficients per choice +/// effective_size = min(256, 50) = 50 +/// +/// Choice 0 reads coefficients [206..256) (last 50 of segment 0) +/// Choice 1 reads coefficients [462..512) (last 50 of segment 1) +/// +/// Given degree=512, MAX_VOTE_BITS=50, num_choices=4: +/// segment_size = 512 / 4 = 128 coefficients per choice +/// effective_size = min(128, 50) = 50 +/// remainder = 512 - (128 * 4) = 0 +/// +/// Choice 0 reads coefficients [ 78..128) (last 50 of segment 0) +/// Choice 1 reads coefficients [206..256) (last 50 of segment 1) +/// Choice 2 reads coefficients [334..384) (last 50 of segment 2) +/// Choice 3 reads coefficients [462..512) (last 50 of segment 3) +/// +/// Given degree=512, MAX_VOTE_BITS=50, num_choices=3: +/// segment_size = 512 / 3 = 170 coefficients per choice +/// effective_size = min(170, 50) = 50 +/// remainder = 512 - (170 * 3) = 2 coefficients (trailing zeros, ignored) +/// +/// Choice 0 reads coefficients [120..170) +/// Choice 1 reads coefficients [290..340) +/// Choice 2 reads coefficients [460..510) +/// +pub fn decode_tally(tally_bytes: &[u8], num_choices: usize) -> Result> { + if num_choices == 0 { + return Err(eyre::eyre!("Number of choices must be positive")); } - // Convert no votes (entire second half) - let mut no = BigUint::from(0u64); - for (i, &value) in no_binary.iter().enumerate() { - let weight = BigUint::from(2u64).pow((no_binary.len() - 1 - i) as u32); - no += BigUint::from(value) * weight; + // Each u64 coefficient is stored as 8 little-endian bytes. + // This gives us the full polynomial coefficient array. + let values = decode_bytes_to_vec_u64(tally_bytes)?; + + // Divide the polynomial evenly into num_choices segments. + // Any leftover coefficients (degree % num_choices) are trailing + // zeros and are ignored. + let segment_size = values.len() / num_choices; + + // Only read the rightmost MAX_VOTE_BITS (50) coefficients from each + // segment to avoid overflow. If the segment is smaller than + // MAX_VOTE_BITS (unlikely with degree=512), use the full segment. + let effective_size = segment_size.min(MAX_VOTE_BITS); + + let mut results = Vec::with_capacity(num_choices); + + for choice_idx in 0..num_choices { + // Find where this choice's segment starts in the array + let segment_start = choice_idx * segment_size; + + // Right-align: skip leading zeros, read only the significant bits + // at the end of the segment + let read_start = segment_start + segment_size - effective_size; + let segment = &values[read_start..read_start + effective_size]; + + // Reconstruct the vote count from binary (big-endian within segment): + // value = segment[0] * 2^(n-1) + segment[1] * 2^(n-2) + ... + segment[n-1] * 2^0 + let mut value = BigUint::from(0u64); + for (i, &v) in segment.iter().enumerate() { + let weight = BigUint::from(2u64).pow((segment.len() - 1 - i) as u32); + value += BigUint::from(v) * weight; + } + + results.push(value); } - Ok(VoteCounts { yes, no }) + Ok(results) } #[cfg(test)] @@ -64,9 +142,21 @@ mod tests { let tally_hex = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000001000000000000000000000000000000010000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000100000000000000010000000000000001000000000000000100000000000000010000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000300000000000000000000000000000003000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000030000000000000003000000000000000300000000000000030000000000000003000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; let bytes = hex::decode(tally_hex.strip_prefix("0x").unwrap_or(tally_hex)).unwrap(); - let result = decode_tally(&bytes).unwrap(); + let result = decode_tally(&bytes, 2).unwrap(); + + assert_eq!(result[0], BigUint::from(10000000000u64)); + assert_eq!(result[1], BigUint::from(30000000000u64)); + } + + #[test] + fn test_decode_tally_with_wrong_num_options() { + // Expected: yes = 10000000000, no = 30000000000 + let tally_hex = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000001000000000000000000000000000000010000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000100000000000000010000000000000001000000000000000100000000000000010000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000300000000000000000000000000000003000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000030000000000000003000000000000000300000000000000030000000000000003000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + let bytes = hex::decode(tally_hex.strip_prefix("0x").unwrap_or(tally_hex)).unwrap(); + let result = decode_tally(&bytes, 3).unwrap(); - assert_eq!(result.yes, BigUint::from(10000000000u64)); - assert_eq!(result.no, BigUint::from(30000000000u64)); + assert!(result[0] != BigUint::from(10000000000u64)); + assert!(result[1] != BigUint::from(30000000000u64)); } } diff --git a/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts b/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts index 4d44ab1693..cd26b6cfa4 100644 --- a/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts +++ b/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts @@ -103,7 +103,7 @@ export const deployCRISPContracts = async () => { address: tokenAddress, blockNumber: await ethers.provider.getBlockNumber(), }, - 'MockCRISPToken', + 'MockVotingToken', chain, ) } diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 9bf00643e2..bf1aed90f7 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -348,20 +348,17 @@ pub async fn register_plaintext_output_published( async move { info!("[e3_id={}] Handling PlaintextOutputPublished", e3_id); + let num_options = repo.get_num_options().await?; + // The plaintextOutput from the event contains the result of the FHE computation. // Decode the tally using the utility function. - let vote_counts = decode_tally(&event.plaintextOutput)?; - - info!( - "[e3_id={}] Votes Option 1 (Yes): {:?}", - e3_id, vote_counts.yes - ); - info!( - "[e3_id={}] Votes Option 2 (No): {:?}", - e3_id, vote_counts.no - ); + let vote_counts = decode_tally(&event.plaintextOutput, num_options)?; + + for (i, count) in vote_counts.iter().enumerate() { + info!("[e3_id={}] Option index: {} votes: {:?}", e3_id, i, count); + } - repo.set_votes(vote_counts.yes, vote_counts.no).await?; + repo.set_votes(vote_counts).await?; repo.update_status("Finished").await?; Ok(()) } diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index f80a6b4ff2..fd1034c150 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -159,8 +159,7 @@ pub struct RoundRequest { #[derive(Debug, Deserialize, Serialize)] pub struct WebResultRequest { pub round_id: u64, - pub option_1_tally: String, - pub option_2_tally: String, + pub tally: Vec, pub option_1_emoji: String, pub option_2_emoji: String, pub total_votes: u64, @@ -206,8 +205,7 @@ pub struct E3 { pub status: String, pub has_voted: Vec, pub vote_count: u64, - pub votes_option_1: String, - pub votes_option_2: String, + pub tally: Vec, // Timing-related pub start_time: u64, @@ -239,8 +237,7 @@ pub struct E3Crisp { pub has_voted: Vec, pub start_time: u64, pub status: String, - pub votes_option_1: String, - pub votes_option_2: String, + pub tally: Vec, pub token_holder_hashes: Vec, pub eligible_addresses: Vec, pub token_address: String, @@ -256,8 +253,7 @@ impl From for WebResultRequest { fn from(e3: E3) -> Self { WebResultRequest { round_id: e3.id, - option_1_tally: e3.votes_option_1, - option_2_tally: e3.votes_option_2, + tally: e3.tally, option_1_emoji: e3.emojis[0].clone(), option_2_emoji: e3.emojis[1].clone(), total_votes: e3.vote_count, diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index 7e9f9b67fe..d4e650b458 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -174,8 +174,7 @@ impl CrispE3Repository { has_voted: vec![], start_time: 0u64, status: "Requested".to_string(), - votes_option_1: "0".to_string(), - votes_option_2: "0".to_string(), + tally: vec![], emojis: generate_emoji(), token_holder_hashes: vec![], eligible_addresses: vec![], @@ -199,6 +198,11 @@ impl CrispE3Repository { Ok(e3) } + pub async fn get_num_options(&self) -> Result { + let e3_crisp = self.get_crisp().await?; + Ok(e3_crisp.num_options.parse::()?) + } + pub async fn get_vote_count(&self) -> Result { let e3_crisp = self.get_crisp().await?; Ok(u64::try_from(e3_crisp.has_voted.len())?) @@ -219,19 +223,25 @@ impl CrispE3Repository { Ok(()) } - pub async fn set_votes(&mut self, option_1: BigUint, option_2: BigUint) -> Result<()> { - info!("set_votes(option_1:{} option_2:{})", option_1, option_2); + pub async fn set_votes(&mut self, votes: Vec) -> Result<()> { + info!( + "set_votes: [{}]", + votes.iter().enumerate() + .map(|(i, v)| format!("option_{}: {}", i, v)) + .collect::>() + .join(", ") + ); + let key = self.crisp_key(); self.store - .modify(&key, |e3_obj: Option| { - e3_obj.map(|mut e| { - e.votes_option_1 = option_1.to_string(); - e.votes_option_2 = option_2.to_string(); - e - }) + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + e.tally = votes.iter().map(|v| v.to_string()).collect(); + e }) - .await - .map_err(|_| eyre::eyre!("Could not append ciphertext_input for '{key}'"))?; + }) + .await + .map_err(|_| eyre::eyre!("Could not set votes for '{key}'"))?; Ok(()) } @@ -250,8 +260,7 @@ impl CrispE3Repository { let e3_crisp = self.get_crisp().await?; Ok(WebResultRequest { round_id: e3.id, - option_1_tally: e3_crisp.votes_option_1, - option_2_tally: e3_crisp.votes_option_2, + tally: e3_crisp.tally, option_1_emoji: e3_crisp.emojis[0].clone(), option_2_emoji: e3_crisp.emojis[1].clone(), end_time: e3.expiration, diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index e931c4de6a..01dc516705 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use crate::server::{models::TokenHolder, CONFIG}; +use crate::server::{models::TokenHolder}; use alloy::primitives::{Address, U256}; use alloy::providers::ProviderBuilder; use alloy::sol; @@ -327,7 +327,7 @@ impl EtherscanClient { let scale_factor = U256::from(10u128.pow(half_decimals as u32)); - for voter in potential_voters { + for voter in potential_voters { match Self::get_past_votes(token_address, voter.address, block_number, rpc_url).await { Ok(votes) => { if votes >= threshold { diff --git a/templates/default/tests/integration.spec.ts b/templates/default/tests/integration.spec.ts index 34874b41be..ed9e81f8ef 100644 --- a/templates/default/tests/integration.spec.ts +++ b/templates/default/tests/integration.spec.ts @@ -173,7 +173,7 @@ describe('Integration', () => { const { waitForEvent } = await setupEventListeners(sdk, store) const threshold: [number, number] = [DEFAULT_E3_CONFIG.threshold_min, DEFAULT_E3_CONFIG.threshold_max] - const startWindow = calculateStartWindow(130) + const startWindow = calculateStartWindow(100) const duration = BigInt(20) const thresholdBfvParams = await sdk.getThresholdBfvParamsSet() const e3ProgramParams = encodeBfvParams(thresholdBfvParams)