Skip to main content

hop_mix/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3//! Deterministic, transcript-bound aggregation of HOP actions.
4
5use 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/// Inputs and optional contributor ordering for an aggregate action.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MixBatch {
16    /// Actions to aggregate by field addition of their polynomials and masks.
17    pub contributors: Vec<Action>,
18    /// Optional permutation of contributor indexes used by the transcript hash.
19    pub permutation_hint: Option<Vec<u32>>,
20}
21
22/// Errors returned when a mix cannot be represented canonically.
23#[derive(Clone, Debug, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum MixError {
26    /// At least one contributor is required.
27    EmptyBatch,
28    /// A permutation was not an exact permutation of all contributor indexes.
29    InvalidPermutation,
30    /// Contributor masks had incompatible dimensions.
31    MaskDimensionMismatch,
32    /// A contributor was not a canonical action.
33    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
60/// Aggregates a batch using field addition and a transcript-derived nonce.
61///
62/// This operation is an additive aggregate, not sequential action composition.
63/// The output nonce commits to every full action hash and the selected order.
64pub 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}