1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3use core::fmt;
6
7use hop_actions::{Action, ActionError};
8use sha2::{Digest, Sha256};
9
10const MIX_DOMAIN: &[u8] = b"HOP-MIX\0\x02";
11const FIELD_MODULUS: u64 = 65_537;
12
13#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MixBatch {
16 pub contributors: Vec<Action>,
18 pub permutation_hint: Option<Vec<u32>>,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum MixError {
26 EmptyBatch,
28 InvalidPermutation,
30 MaskDimensionMismatch,
32 InvalidAction(ActionError),
34}
35
36impl fmt::Display for MixError {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 Self::EmptyBatch => f.write_str("a mix requires at least one contributor"),
40 Self::InvalidPermutation => {
41 f.write_str("mix permutation must contain every contributor index exactly once")
42 }
43 Self::MaskDimensionMismatch => {
44 f.write_str("mix contributor masks have incompatible dimensions")
45 }
46 Self::InvalidAction(error) => write!(f, "invalid mix contributor: {error}"),
47 }
48 }
49}
50
51impl std::error::Error for MixError {
52 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
53 match self {
54 Self::InvalidAction(error) => Some(error),
55 _ => None,
56 }
57 }
58}
59
60pub fn build_mix(batch: MixBatch) -> Result<Action, MixError> {
65 if batch.contributors.is_empty() {
66 return Err(MixError::EmptyBatch);
67 }
68
69 let dimension = batch
70 .contributors
71 .iter()
72 .find_map(|action| action.mask_row0.as_ref().map(Vec::len))
73 .unwrap_or(0);
74 for action in &batch.contributors {
75 action.validate().map_err(MixError::InvalidAction)?;
76 if action
77 .mask_row0
78 .as_ref()
79 .is_some_and(|mask| mask.len() != dimension)
80 {
81 return Err(MixError::MaskDimensionMismatch);
82 }
83 }
84
85 let order = contributor_order(batch.contributors.len(), batch.permutation_hint)?;
86 let coefficient_count = batch
87 .contributors
88 .iter()
89 .map(|action| action.g_coeffs.len())
90 .max()
91 .ok_or(MixError::EmptyBatch)?;
92 let mut coefficients = vec![0; coefficient_count];
93 let mut mask = (dimension != 0).then(|| vec![0; dimension]);
94 let mut transcript = Sha256::new();
95 transcript.update(MIX_DOMAIN);
96 transcript.update((order.len() as u128).to_le_bytes());
97
98 for index in order {
99 let action = &batch.contributors[index];
100 transcript.update((index as u128).to_le_bytes());
101 transcript.update(action.hash());
102 for (sum, coefficient) in coefficients.iter_mut().zip(&action.g_coeffs) {
103 *sum = add_field(*sum, *coefficient);
104 }
105 if let (Some(sum), Some(contribution)) = (&mut mask, &action.mask_row0) {
106 for (value, addend) in sum.iter_mut().zip(contribution) {
107 *value = add_field(*value, *addend);
108 }
109 }
110 }
111
112 while coefficients.len() > 1 && coefficients.last() == Some(&0) {
113 coefficients.pop();
114 }
115 let digest = transcript.finalize();
116 let mut nonce_bytes = [0_u8; 8];
117 nonce_bytes.copy_from_slice(&digest[..8]);
118 let nonce = u64::from_le_bytes(nonce_bytes);
119 Ok(Action {
120 g_coeffs: coefficients,
121 nonce,
122 mask_row0: mask,
123 })
124}
125
126fn contributor_order(count: usize, permutation: Option<Vec<u32>>) -> Result<Vec<usize>, MixError> {
127 let Some(permutation) = permutation else {
128 return Ok((0..count).collect());
129 };
130 if permutation.len() != count {
131 return Err(MixError::InvalidPermutation);
132 }
133 let mut seen = vec![false; count];
134 let mut order = Vec::with_capacity(count);
135 for encoded in permutation {
136 let index = encoded as usize;
137 if index >= count || seen[index] {
138 return Err(MixError::InvalidPermutation);
139 }
140 seen[index] = true;
141 order.push(index);
142 }
143 Ok(order)
144}
145
146fn add_field(left: u64, right: u64) -> u64 {
147 ((left as u128 + right as u128) % FIELD_MODULUS as u128) as u64
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn action(g_coeffs: Vec<u64>, nonce: u64) -> Action {
155 Action {
156 g_coeffs,
157 nonce,
158 mask_row0: None,
159 }
160 }
161
162 #[test]
163 fn addition_is_correct_across_u64_overflow_boundary() {
164 let mixed = build_mix(MixBatch {
165 contributors: vec![action(vec![65_536], 1), action(vec![2], 2)],
166 permutation_hint: None,
167 })
168 .unwrap();
169 assert_eq!(mixed.g_coeffs, vec![1]);
170 }
171
172 #[test]
173 fn permutation_changes_transcript_nonce_but_not_sum() {
174 let contributors = vec![action(vec![1, 2], 1), action(vec![3], 2)];
175 let natural = build_mix(MixBatch {
176 contributors: contributors.clone(),
177 permutation_hint: None,
178 })
179 .unwrap();
180 let reversed = build_mix(MixBatch {
181 contributors,
182 permutation_hint: Some(vec![1, 0]),
183 })
184 .unwrap();
185 assert_eq!(natural.g_coeffs, reversed.g_coeffs);
186 assert_ne!(natural.nonce, reversed.nonce);
187 }
188
189 #[test]
190 fn rejects_empty_and_malformed_permutations() {
191 assert_eq!(
192 build_mix(MixBatch {
193 contributors: Vec::new(),
194 permutation_hint: None,
195 }),
196 Err(MixError::EmptyBatch)
197 );
198 assert_eq!(
199 build_mix(MixBatch {
200 contributors: vec![action(vec![1], 1), action(vec![2], 2)],
201 permutation_hint: Some(vec![0, 0]),
202 }),
203 Err(MixError::InvalidPermutation)
204 );
205 }
206}