1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3use core::fmt;
10
11use hop_core::{poly_matrix_eval_checked, CompanionMatrix, Field, Mat};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15const ACTION_HASH_DOMAIN: &[u8] = b"HOP-ACTION\0\x02";
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Action {
21 pub g_coeffs: Vec<u64>,
23 pub nonce: u64,
25 #[serde(default)]
27 pub mask_row0: Option<Vec<u64>>,
28}
29
30impl Action {
31 pub fn try_new(
33 g_coeffs: Vec<u64>,
34 nonce: u64,
35 mask_row0: Option<Vec<u64>>,
36 dimension: usize,
37 ) -> Result<Self, ActionError> {
38 let action = Self {
39 g_coeffs,
40 nonce,
41 mask_row0,
42 };
43 action.validate_for_dimension(dimension)?;
44 Ok(action)
45 }
46
47 pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), ActionError> {
49 if dimension == 0 {
50 return Err(ActionError::ZeroDimension);
51 }
52 self.validate()?;
53 if let Some(mask) = &self.mask_row0 {
54 if mask.len() != dimension {
55 return Err(ActionError::MaskDimensionMismatch {
56 expected: dimension,
57 actual: mask.len(),
58 });
59 }
60 }
61 Ok(())
62 }
63
64 pub fn validate(&self) -> Result<(), ActionError> {
66 if self.g_coeffs.is_empty() {
67 return Err(ActionError::EmptyPolynomial);
68 }
69 validate_canonical_values(&self.g_coeffs, "polynomial")?;
70 if let Some(mask) = &self.mask_row0 {
71 if mask.is_empty() {
72 return Err(ActionError::EmptyMask);
73 }
74 validate_canonical_values(mask, "mask")?;
75 }
76 Ok(())
77 }
78
79 pub fn hash(&self) -> [u8; 32] {
81 hash_action(self)
82 }
83}
84
85#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ActionSeq(pub Vec<Action>);
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Capability {
92 pub max_degree: usize,
94 pub topic: [u8; 32],
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct Attestation {
101 pub subject: [u8; 32],
103 pub scope: Capability,
105 pub sig: Vec<u8>,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
111#[non_exhaustive]
112pub enum ActionError {
113 ZeroDimension,
115 EmptyPolynomial,
117 EmptyMask,
119 NonCanonicalFieldValue {
121 field: &'static str,
123 index: usize,
125 value: u64,
127 },
128 MaskDimensionMismatch {
130 expected: usize,
132 actual: usize,
134 },
135 StateDimensionMismatch {
137 expected: usize,
139 actual: usize,
141 },
142 InvalidTransform(hop_core::CoreError),
144}
145
146impl fmt::Display for ActionError {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 match self {
149 Self::ZeroDimension => f.write_str("an action dimension must be nonzero"),
150 Self::EmptyPolynomial => f.write_str("an action polynomial must not be empty"),
151 Self::EmptyMask => f.write_str("a present action mask must not be empty"),
152 Self::NonCanonicalFieldValue {
153 field,
154 index,
155 value,
156 } => write!(
157 f,
158 "{field} value at index {index} is not canonical: {value} >= {}",
159 Field::MOD
160 ),
161 Self::MaskDimensionMismatch { expected, actual } => write!(
162 f,
163 "mask dimension mismatch: expected {expected}, got {actual}"
164 ),
165 Self::StateDimensionMismatch { expected, actual } => write!(
166 f,
167 "state dimension mismatch: expected {expected}, got {actual}"
168 ),
169 Self::InvalidTransform(error) => write!(f, "invalid action transform: {error}"),
170 }
171 }
172}
173
174impl std::error::Error for ActionError {
175 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
176 match self {
177 Self::InvalidTransform(error) => Some(error),
178 _ => None,
179 }
180 }
181}
182
183pub fn hash_action(action: &Action) -> [u8; 32] {
189 let mut hasher = Sha256::new();
190 hasher.update(ACTION_HASH_DOMAIN);
191 hasher.update(action.nonce.to_le_bytes());
192 update_values(&mut hasher, &action.g_coeffs);
193 match &action.mask_row0 {
194 None => hasher.update([0]),
195 Some(mask) => {
196 hasher.update([1]);
197 update_values(&mut hasher, mask);
198 }
199 }
200 hasher.finalize().into()
201}
202
203pub fn action_transform(companion: &CompanionMatrix, action: &Action) -> Result<Mat, ActionError> {
205 action.validate_for_dimension(companion.dimension())?;
206 poly_matrix_eval_checked(&action.g_coeffs, companion.matrix())
207 .map_err(ActionError::InvalidTransform)
208}
209
210pub fn apply_action(
212 companion: &CompanionMatrix,
213 state: &[Field],
214 action: &Action,
215) -> Result<Vec<Field>, ActionError> {
216 let dimension = companion.dimension();
217 if state.len() != dimension {
218 return Err(ActionError::StateDimensionMismatch {
219 expected: dimension,
220 actual: state.len(),
221 });
222 }
223 action.validate_for_dimension(dimension)?;
224 let mut result = vec![Field::zero(); dimension];
225 for coefficient in action.g_coeffs.iter().rev() {
226 result = companion
227 .try_apply(&result)
228 .map_err(ActionError::InvalidTransform)?;
229 let scalar = Field::from(*coefficient);
230 for (output, input) in result.iter_mut().zip(state) {
231 *output += scalar * *input;
232 }
233 }
234 Ok(result)
235}
236
237fn validate_canonical_values(values: &[u64], field: &'static str) -> Result<(), ActionError> {
238 if let Some((index, &value)) = values
239 .iter()
240 .enumerate()
241 .find(|(_, value)| **value >= Field::MOD)
242 {
243 return Err(ActionError::NonCanonicalFieldValue {
244 field,
245 index,
246 value,
247 });
248 }
249 Ok(())
250}
251
252fn update_values(hasher: &mut Sha256, values: &[u64]) {
253 hasher.update((values.len() as u128).to_le_bytes());
254 for value in values {
255 hasher.update(value.to_le_bytes());
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn companion() -> CompanionMatrix {
264 CompanionMatrix::try_from_coeffs(&[4, 3, 2]).unwrap()
265 }
266
267 #[test]
268 fn hash_framing_distinguishes_coefficients_from_mask() {
269 let coefficients_then_mask = Action {
270 g_coeffs: vec![1],
271 nonce: 7,
272 mask_row0: Some(vec![2, 0, 0]),
273 };
274 let coefficients_only = Action {
275 g_coeffs: vec![1, 2, 0, 0],
276 nonce: 7,
277 mask_row0: None,
278 };
279 assert_ne!(
280 hash_action(&coefficients_then_mask),
281 hash_action(&coefficients_only)
282 );
283 }
284
285 #[test]
286 fn action_applies_polynomial_transform() {
287 let c = companion();
288 let state = vec![Field::one(), Field::zero(), Field::zero()];
289 let identity = Action::try_new(vec![1], 0, None, 3).unwrap();
290 let transition = Action::try_new(vec![0, 1], 1, None, 3).unwrap();
291
292 assert_eq!(apply_action(&c, &state, &identity).unwrap(), state);
293 assert_eq!(
294 apply_action(&c, &state, &transition).unwrap(),
295 c.try_apply(&state).unwrap()
296 );
297 let polynomial = Action::try_new(vec![7, 11, 13, 17], 2, None, 3).unwrap();
298 let dense = action_transform(&c, &polynomial).unwrap();
299 let expected: Vec<Field> = dense
300 .iter()
301 .map(|row| {
302 row.iter()
303 .zip(&state)
304 .fold(Field::zero(), |sum, (left, right)| sum + (*left * *right))
305 })
306 .collect();
307 assert_eq!(apply_action(&c, &state, &polynomial).unwrap(), expected);
308 }
309
310 #[test]
311 fn rejects_ambiguous_or_noncanonical_wire_values() {
312 assert_eq!(
313 Action::try_new(Vec::new(), 0, None, 3),
314 Err(ActionError::EmptyPolynomial)
315 );
316 assert_eq!(
317 Action::try_new(vec![1], 0, None, 0),
318 Err(ActionError::ZeroDimension)
319 );
320 assert_eq!(
321 Action::try_new(vec![1], 0, Some(Vec::new()), 3),
322 Err(ActionError::EmptyMask)
323 );
324 assert!(matches!(
325 Action::try_new(vec![Field::MOD], 0, None, 3),
326 Err(ActionError::NonCanonicalFieldValue { .. })
327 ));
328 assert!(matches!(
329 Action::try_new(vec![1], 0, Some(vec![1]), 3),
330 Err(ActionError::MaskDimensionMismatch { .. })
331 ));
332 }
333}