Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c335590
Create the catalyst-contest crate
stanislav-tkach Dec 3, 2025
1f73105
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 3, 2025
d45601d
ElgamalRistretto255Choice encoding decoding
stanislav-tkach Dec 3, 2025
e4f195b
Provide size for content ballot map
stanislav-tkach Dec 3, 2025
c805686
Decode choices
stanislav-tkach Dec 3, 2025
32837b1
Add some documentation
stanislav-tkach Dec 3, 2025
e97635b
Move ElgamalRistretto255Choice to a module
stanislav-tkach Dec 4, 2025
04e10f2
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 4, 2025
2057d3d
Move row proof to a separate module
stanislav-tkach Dec 4, 2025
6cfd573
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 4, 2025
2467add
More encoding/decoding
stanislav-tkach Dec 4, 2025
9cad94c
Fix some Clippy warnings and update tests slightly
stanislav-tkach Dec 4, 2025
7de11cc
ProofResponse, ProofAnnouncementElement, etc.
stanislav-tkach Dec 4, 2025
ec6d71a
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 9, 2025
dcd9e1d
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 11, 2025
17ec407
Use Ciphertext type
stanislav-tkach Dec 11, 2025
708d180
Rename Choices::Encrypted enum variant
stanislav-tkach Dec 11, 2025
e264369
Use placeholders for column and matrix proofs
stanislav-tkach Dec 11, 2025
43685fb
Use proptest
stanislav-tkach Dec 12, 2025
8cbed27
Update the Choices implementation
stanislav-tkach Dec 12, 2025
8c0181c
Fix ContentBallot decoding
stanislav-tkach Dec 12, 2025
087f7d4
Fix Clippy
stanislav-tkach Dec 15, 2025
5bfe354
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 15, 2025
6b418bd
Fix after merge
stanislav-tkach Dec 15, 2025
1b6bb6f
Move contest ballot to a nested module
stanislav-tkach Dec 15, 2025
dc45a72
Remove unused files
stanislav-tkach Dec 15, 2025
6c6325d
Split modules
stanislav-tkach Dec 15, 2025
ba405b4
Add constants for choices variants
stanislav-tkach Dec 15, 2025
8883265
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 16, 2025
a9be7ed
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 16, 2025
fded8d1
Update catalyst voting crate version
stanislav-tkach Dec 16, 2025
2ca4f55
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 17, 2025
b1a731c
Rename ContestBallot to ContestBallotPayload
stanislav-tkach Dec 17, 2025
47e2fc1
Use u64 for choices
stanislav-tkach Dec 17, 2025
082af57
Fix typo
stanislav-tkach Dec 17, 2025
4f03a9e
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 18, 2025
eb866fe
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 18, 2025
c182670
Merge branch 'main' into catalyst-contest-crate
stanislav-tkach Dec 18, 2025
70a5ccb
Merge branch 'main' into catalyst-contest-crate
Mr-Leshiy Dec 18, 2025
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
9 changes: 8 additions & 1 deletion rust/catalyst-contest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ workspace = true
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
catalyst-signed-doc = { version = "0.0.10", path = "../signed_doc" }
futures = "0.3.31"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
minicbor = { version = "0.25.1", features = ["alloc", "derive", "half"] }
proptest = { version = "1.6.0", features = ["attr-macro"] }
proptest-derive = "0.5.1"

cbork-utils = { version = "0.0.2", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "cbork-utils-v0.0.2" }
# TODO: Use tag instead of path.
catalyst-signed-doc = { version = "0.0.10", path = "../signed_doc" }
catalyst-voting = { version = "0.0.2", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-voting/v0.0.2" }
159 changes: 159 additions & 0 deletions rust/catalyst-contest/src/contest_ballot/ballot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//! An individual Ballot cast in a Contest by a registered user.

use std::collections::BTreeMap;

use cbork_utils::decode_helper::decode_map_len;
use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write};

use crate::{Choices, EncryptedChoices};

/// An individual Ballot cast in a Contest by a registered user.
///
/// The CDDL schema:
/// ```cddl
/// contest-ballot-payload = {
/// + uint => choices
/// ? "column-proof" : column-proof
/// ? "matrix-proof" : matrix-proof
/// ? "voter-choice" : voter-choice
/// }
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ContentBallotPayload {
/// A map of voters choices.
pub choices: BTreeMap<u64, Choices>,
/// A universal encrypted column proof.
///
/// This is a placeholder for now and should always be `None`.
pub column_proof: Option<()>,
/// A universal encrypted matrix proof.
///
/// This is a placeholder for now and should always be `None`.
pub matrix_proof: Option<()>,
/// An encrypted voter choice payload.
pub voter_choices: Option<EncryptedChoices>,
}

impl Decode<'_, ()> for ContentBallotPayload {
fn decode(
d: &mut Decoder<'_>,
ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
use minicbor::data::Type;

let len = decode_map_len(d, "content ballot payload")?;

let mut choices = BTreeMap::new();
let column_proof = None;
let matrix_proof = None;
let mut voter_choices = None;
for _ in 0..len {
match d.datatype()? {
Type::U8 | Type::U16 | Type::U32 | Type::U64 => {
let key = d.u64()?;
let val = Choices::decode(d, ctx)?;
choices.insert(key, val);
},
Type::String => {
match d.str()? {
"column-proof" => {
return Err(minicbor::decode::Error::message(
"column-proof is a placeholder and shouldn't be used",
));
},
"matrix-proof" => {
return Err(minicbor::decode::Error::message(
"matrix-proof is a placeholder and shouldn't be used",
));
},
"voter-choices" => voter_choices = Some(EncryptedChoices::decode(d, ctx)?),
key => {
return Err(minicbor::decode::Error::message(format!(
"Unexpected content ballot payload key value: {key:?}"
)));
},
}
},
t => {
return Err(minicbor::decode::Error::message(format!(
"Unexpected content ballot payload key type: {t:?}"
)));
},
}
}

Ok(Self {
choices,
column_proof,
matrix_proof,
voter_choices,
})
}
}

impl Encode<()> for ContentBallotPayload {
fn encode<W: Write>(
&self,
e: &mut Encoder<W>,
_ctx: &mut (),
) -> Result<(), minicbor::encode::Error<W::Error>> {
let len = u64::try_from(self.choices.len())
.map_err(minicbor::encode::Error::message)?
.checked_add(u64::from(self.column_proof.is_some()))
.and_then(|v| v.checked_add(u64::from(self.matrix_proof.is_some())))
.and_then(|v| v.checked_add(u64::from(self.voter_choices.is_some())))
.ok_or_else(|| {
minicbor::encode::Error::message("contest ballot payload map length overflow")
})?;
e.map(len)?;

for (&key, val) in &self.choices {
e.u64(key)?.encode(val)?;
}
if let Some(column_proof) = self.column_proof.as_ref() {
e.str("column-proof")?.encode(column_proof)?;
}
if let Some(matrix_proof) = self.matrix_proof.as_ref() {
e.str("matrix-proof")?.encode(matrix_proof)?;
}
if let Some(voter_choices) = self.voter_choices.as_ref() {
e.str("voter-choices")?.encode(voter_choices)?;
}

Ok(())
}
}

#[cfg(test)]
mod tests {
use catalyst_voting::crypto::elgamal::Ciphertext;

use super::*;
use crate::contest_ballot::encrypted_block::EncryptedBlock;

#[test]
fn roundtrip() {
let original = ContentBallotPayload {
choices: [
(1, Choices::Clear(vec![1, 2, 3, 4, 5])),
(2, Choices::Encrypted {
choices: vec![Ciphertext::zero()],
row_proof: None,
}),
]
.into(),
column_proof: None,
matrix_proof: None,
voter_choices: Some(EncryptedChoices(vec![
EncryptedBlock([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]),
EncryptedBlock([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
])),
};
let mut buffer = Vec::new();
original
.encode(&mut Encoder::new(&mut buffer), &mut ())
.unwrap();
let decoded = ContentBallotPayload::decode(&mut Decoder::new(&buffer), &mut ()).unwrap();
assert_eq!(original, decoded);
}
}
157 changes: 157 additions & 0 deletions rust/catalyst-contest/src/contest_ballot/choices.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! Voters Choices.

use catalyst_voting::crypto::{elgamal::Ciphertext, zk_unit_vector::UnitVectorProof};
use cbork_utils::decode_helper::decode_array_len;
use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write};

/// A clear choice indicator. See the `Choices` CBOR schema for the details.
const CLEAR_CHOICE: u8 = 0;

/// An encrypted choice indicator. See the `Choices` CBOR schema for the details.
const ENCRYPTED_CHOICE: u8 = 1;

/// Voters Choices.
///
/// The CDDL schema:
/// ```cddl
/// choices = [ 0, clear-choices ] /
/// [ 1, elgamal-ristretto255-encrypted-choices ]
///
/// clear-choices = ( +clear-choice )
///
/// clear-choice = uint
///
/// elgamal-ristretto255-encrypted-choices = [
/// [+ elgamal-ristretto255-encrypted-choice]
/// ? row-proof
/// ]
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Choices {
/// A universal unencrypted set of choices.
Clear(Vec<u64>),
/// ElGamal/Ristretto255 encrypted choices.
Encrypted {
/// A list of ElGamal/Ristretto255 encrypted choices.
choices: Vec<Ciphertext>,
/// A universal encrypted row proof.
row_proof: Option<UnitVectorProof>,
},
}

impl Decode<'_, ()> for Choices {
fn decode(
d: &mut Decoder<'_>,
ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
let len = decode_array_len(d, "choices")?;
if len < 2 {
return Err(minicbor::decode::Error::message(format!(
"Unexpected choices array length {len}, expected at least 2"
)));
}
match u8::decode(d, ctx)? {
CLEAR_CHOICE => {
let mut values = Vec::with_capacity(
len.checked_sub(1)
.ok_or_else(|| {
minicbor::decode::Error::message("Choices array length underflow")
})?
.try_into()
.map_err(minicbor::decode::Error::message)?,
);
for _ in 1..len {
values.push(u64::decode(d, ctx)?);
}
Ok(Self::Clear(values))
},
ENCRYPTED_CHOICE => {
if len > 2 {
return Err(minicbor::decode::Error::message(format!(
"Unexpected encrypted choices array length {len}, expected 2"
)));
}

let len = decode_array_len(d, "elgamal-ristretto255-encrypted-choices")?;
if !(1..=2).contains(&len) {
return Err(minicbor::decode::Error::message(format!(
"Unexpected elgamal-ristretto255-encrypted-choices array length {len}, expected 1 or 2"
)));
}
let choices = <Vec<Ciphertext>>::decode(d, ctx)?;
let mut row_proof = None;
if len == 2 {
row_proof = Some(UnitVectorProof::decode(d, ctx)?);
}
Ok(Self::Encrypted { choices, row_proof })
},
val => {
Err(minicbor::decode::Error::message(format!(
"Unexpected choices value: {val}"
)))
},
}
}
}

impl Encode<()> for Choices {
fn encode<W: Write>(
&self,
e: &mut Encoder<W>,
ctx: &mut (),
) -> Result<(), minicbor::encode::Error<W::Error>> {
match self {
Choices::Clear(choices) => {
e.array((choices.len() as u64).checked_add(1).ok_or_else(|| {
minicbor::encode::Error::message("Clear choices array length overflow")
})?)?;
0.encode(e, ctx)?;
for choice in choices {
choice.encode(e, ctx)?;
}
},
Choices::Encrypted { choices, row_proof } => {
e.array(2)?;
1.encode(e, ctx)?;
// Allowed because 1 + 1 will never result in overflow.
#[allow(clippy::arithmetic_side_effects)]
e.array(1 + u64::from(row_proof.is_some()))?;
choices.encode(e, ctx)?;
if let Some(row_proof) = row_proof {
row_proof.encode(e, ctx)?;
}
},
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn clear_roundtrip() {
let original = Choices::Clear(vec![1, 2, 3]);
let mut buffer = Vec::new();
original
.encode(&mut Encoder::new(&mut buffer), &mut ())
.unwrap();
let decoded = Choices::decode(&mut Decoder::new(&buffer), &mut ()).unwrap();
assert_eq!(original, decoded);
}

#[test]
fn encrypted_roundtrip() {
let original = Choices::Encrypted {
choices: vec![Ciphertext::zero(), Ciphertext::zero()],
row_proof: None,
};
let mut buffer = Vec::new();
original
.encode(&mut Encoder::new(&mut buffer), &mut ())
.unwrap();
let decoded = Choices::decode(&mut Decoder::new(&buffer), &mut ()).unwrap();
assert_eq!(original, decoded);
}
}
52 changes: 52 additions & 0 deletions rust/catalyst-contest/src/contest_ballot/encrypted_block.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! An AES-CTR encrypted data block.

use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write};

/// A length of the encrypted block byte array.
const ENCRYPTED_BLOCK_LEN: usize = 16;

/// An AES-CTR encrypted data block.
///
/// The CDDL schema:
/// ```cddl
/// aes-ctr-encrypted-block = bytes .size 16
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
pub struct EncryptedBlock(pub [u8; ENCRYPTED_BLOCK_LEN]);

impl Decode<'_, ()> for EncryptedBlock {
fn decode(
d: &mut Decoder<'_>,
ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
<[u8; ENCRYPTED_BLOCK_LEN]>::decode(d, ctx).map(Self)
}
}

impl Encode<()> for EncryptedBlock {
fn encode<W: Write>(
&self,
e: &mut Encoder<W>,
ctx: &mut (),
) -> Result<(), minicbor::encode::Error<W::Error>> {
self.0.encode(e, ctx)
}
}

#[cfg(test)]
mod tests {
use proptest::property_test;

use super::*;

#[property_test]
fn roundtrip(original: EncryptedBlock) {
let mut buffer = Vec::new();
original
.encode(&mut Encoder::new(&mut buffer), &mut ())
.unwrap();
let decoded = EncryptedBlock::decode(&mut Decoder::new(&buffer), &mut ()).unwrap();
assert_eq!(original, decoded);
}
}
Loading
Loading