Skip to main content

hop_actions/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3//! Canonically encoded polynomial actions for HOP channels.
4//!
5//! An action describes the linear transform `g(C)` for a channel companion
6//! matrix `C`. Actions over the same matrix commute, because they are
7//! polynomials in the same matrix.
8
9use 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/// A channel action represented by a polynomial in the channel matrix.
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Action {
21    /// Polynomial coefficients in ascending degree order.
22    pub g_coeffs: Vec<u64>,
23    /// Caller-controlled value used by replay and ordering policies.
24    pub nonce: u64,
25    /// Optional canonical projection-mask row.
26    #[serde(default)]
27    pub mask_row0: Option<Vec<u64>>,
28}
29
30impl Action {
31    /// Constructs and validates an action for a channel dimension.
32    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    /// Validates the polynomial and optional mask for a channel dimension.
48    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    /// Validates dimension-independent canonical action invariants.
65    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    /// Returns the domain-separated canonical hash of this action.
80    pub fn hash(&self) -> [u8; 32] {
81        hash_action(self)
82    }
83}
84
85/// An ordered sequence of actions.
86#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ActionSeq(pub Vec<Action>);
88
89/// Limits an action polynomial to a topic and maximum degree.
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Capability {
92    /// Maximum permitted polynomial degree.
93    pub max_degree: usize,
94    /// Topic identifier covered by the capability.
95    pub topic: [u8; 32],
96}
97
98/// An opaque signature over a subject and capability.
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct Attestation {
101    /// Subject identifier receiving the capability.
102    pub subject: [u8; 32],
103    /// Capability granted to the subject.
104    pub scope: Capability,
105    /// Signature bytes interpreted by the protocol layer.
106    pub sig: Vec<u8>,
107}
108
109/// Errors produced while validating or evaluating an action.
110#[derive(Clone, Debug, PartialEq, Eq)]
111#[non_exhaustive]
112pub enum ActionError {
113    /// A channel transform cannot target a zero-dimensional state.
114    ZeroDimension,
115    /// The action omitted its transform polynomial.
116    EmptyPolynomial,
117    /// A present mask must contain at least one field value.
118    EmptyMask,
119    /// A wire value was not a canonical field representative.
120    NonCanonicalFieldValue {
121        /// Logical field containing the value.
122        field: &'static str,
123        /// Index of the invalid value.
124        index: usize,
125        /// Invalid value.
126        value: u64,
127    },
128    /// The optional projection mask did not match the channel dimension.
129    MaskDimensionMismatch {
130        /// Required mask length.
131        expected: usize,
132        /// Observed mask length.
133        actual: usize,
134    },
135    /// The state did not match the channel dimension.
136    StateDimensionMismatch {
137        /// Required state length.
138        expected: usize,
139        /// Observed state length.
140        actual: usize,
141    },
142    /// The checked HOP Core transform evaluator rejected the matrix.
143    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
183/// Returns the domain-separated canonical SHA-256 hash of an action.
184///
185/// Every variable-length field is length-prefixed, and the optional mask has
186/// an explicit presence byte. This prevents structural collisions between
187/// coefficient and mask encodings.
188pub 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
203/// Evaluates an action polynomial as `g(C)` using checked HOP Core operations.
204pub 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
210/// Applies the action transform `g(C)` to a channel state.
211pub 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}