Skip to main content

hopr_protocol_pix/
params.rs

1use crate::{MAX_POLY_THRESHOLD, MAX_POLYS_PER_SSA, MIN_POLY_THRESHOLD, generator::SsaGeneratorConfig};
2
3/// Why a [`PixParams`] quadruple was rejected.
4///
5/// `surplus_shares` has no variant: its permitted range is the whole of `u8`, so it cannot be out of
6/// range once it has a type.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
8pub enum InvalidPixParams {
9    #[error("polynomials per SSA must be between 1 and {MAX_POLYS_PER_SSA}, got {0}")]
10    PolysPerSsa(u16),
11    #[error("polynomial threshold must be between {MIN_POLY_THRESHOLD} and {MAX_POLY_THRESHOLD}, got {0}")]
12    SharesPerPoly(u8),
13    #[error("unknown PIX curve suite identifier {0}")]
14    UnknownSuite(u8),
15}
16
17/// The elliptic curve a PIX deployment instantiates [`PixSpec`](crate::PixSpec) over.
18///
19/// Every curve-sized field in the PIX handshake — each coefficient commitment, the commitment proof
20/// of knowledge — is sized by this choice, while the messages carrying them are versioned only by
21/// `StartProtocol`'s own version byte. Two peers built for different curves therefore agree on the
22/// protocol version and disagree on where every element boundary falls, so this rides in
23/// [`PixParams`] to make the disagreement visible in the one field whose size *does not* depend on
24/// the curve, before either side interprets one that does.
25///
26/// It is not negotiated. The Exit accepts or refuses what the Entry offers; see
27/// [`PixSpec::PIX_SUITE`](crate::PixSpec::PIX_SUITE).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
29#[repr(u8)]
30pub enum PixSuite {
31    /// Baby JubJub, the production default (`pix-bjj`).
32    ///
33    /// Zero so that a word packed before this field existed decodes as the curve those builds
34    /// overwhelmingly ran. See [`PixParams::try_from_u32`].
35    #[default]
36    BabyJubJub = 0,
37    /// secp256k1, giving Ethereum-shaped deposit addresses (`pix-secp256k1`).
38    Secp256k1 = 1,
39}
40
41impl PixSuite {
42    /// Decodes the two-bit wire form, rejecting the two values no curve claims.
43    pub const fn try_from_bits(bits: u8) -> Result<Self, InvalidPixParams> {
44        match bits {
45            0 => Ok(Self::BabyJubJub),
46            1 => Ok(Self::Secp256k1),
47            other => Err(InvalidPixParams::UnknownSuite(other)),
48        }
49    }
50}
51
52impl std::fmt::Display for PixSuite {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str(match self {
55            Self::BabyJubJub => "BabyJubJub",
56            Self::Secp256k1 => "secp256k1",
57        })
58    }
59}
60
61/// Bit position of [`PixParams::suite`] within the packed `u32`.
62const SUITE_SHIFT: u32 = 30;
63/// Bit position of [`PixParams::polys_per_ssa`] within the packed `u32`.
64const POLYS_SHIFT: u32 = 16;
65/// Mask applied to the polynomial count after shifting, i.e. the 14 bits below the suite.
66const POLYS_MASK: u32 = 0x3fff;
67/// Bit position of [`PixParams::shares_per_poly`] within the packed `u32`.
68const SHARES_SHIFT: u32 = 8;
69/// Bit position of the packed `u32` within [`PixParams::into_additional_data`]'s `u64`.
70const ADDITIONAL_DATA_SHIFT: u32 = 32;
71
72/// The suite occupies the top two bits of the polynomial-count field, which are free only because
73/// [`MAX_POLYS_PER_SSA`] fits in 14 bits. Raising that ceiling into them would silently corrupt the
74/// suite of every packed word, so it is a build failure instead.
75const _: () = assert!(
76    MAX_POLYS_PER_SSA as u32 <= POLYS_MASK,
77    "MAX_POLYS_PER_SSA no longer fits below the PixParams suite bits"
78);
79
80/// Everything about PIX two nodes must agree on for a Session, and the only encoding of it: three
81/// dimensions and the curve suite they are dimensions of.
82///
83/// The same quadruple is packed into two different wire fields — the `SsaRequest` `params` word and
84/// the options word of `StartInitiation::additional_data` — and both go through this type. Every
85/// earlier version of this had the shifts written out by hand at each site, in two mutually
86/// inconsistent shapes, which is why the packing lives behind a constructor rather than in the
87/// callers.
88///
89/// Named fields rather than a `(u16, u8, u8, PixSuite)` tuple: `polys_per_ssa` and `shares_per_poly`
90/// are interchangeable to the type system and *not* interchangeable to the protocol, while their
91/// product — which is all the Exit compares — is identical either way. A transposition therefore
92/// announced valid-looking dimensions against a correct quota, and the only thing that caught it was
93/// `SessionManager::new_session` requiring both to match the locally installed generator exactly,
94/// which is a check about something else entirely.
95///
96/// The fields are private because [`try_new`](Self::try_new) is what enforces the ranges; holding a
97/// `PixParams` is what makes [`to_u32`](Self::to_u32) infallible.
98///
99/// The [`suite`](Self::suite) is here for the same reason the dimensions are: it is something both
100/// nodes must agree on, and equality of this type is what the Entry already checks against the
101/// Exit's echo, so carrying it here gets that direction for free. Note that it is not a dimension —
102/// it does not enter the quota — so code that speaks about what the deposit buys is right to name
103/// only the other three.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub struct PixParams {
106    polys_per_ssa: u16,
107    shares_per_poly: u8,
108    surplus_shares: u8,
109    suite: PixSuite,
110}
111
112impl PixParams {
113    /// Validates and assembles the quadruple.
114    pub const fn try_new(
115        polys_per_ssa: u16,
116        shares_per_poly: u8,
117        surplus_shares: u8,
118        suite: PixSuite,
119    ) -> Result<Self, InvalidPixParams> {
120        if polys_per_ssa == 0 || polys_per_ssa > MAX_POLYS_PER_SSA {
121            return Err(InvalidPixParams::PolysPerSsa(polys_per_ssa));
122        }
123        // No upper-bound check: `MAX_POLY_THRESHOLD` is `u8::MAX`, so the field cannot hold a value
124        // above it. That is the point of narrowing the threshold to a byte — see the constant.
125        if shares_per_poly < MIN_POLY_THRESHOLD {
126            return Err(InvalidPixParams::SharesPerPoly(shares_per_poly));
127        }
128        Ok(Self {
129            polys_per_ssa,
130            shares_per_poly,
131            surplus_shares,
132            suite,
133        })
134    }
135
136    /// Validates and assembles the dimensions under the curve suite of `S`.
137    ///
138    /// The constructor to prefer wherever a concrete spec is in scope, because it makes the suite
139    /// impossible to state wrongly: the shares a node produces and the suite it announces then come
140    /// from the same place.
141    pub fn try_new_for<S: crate::PixSpec>(
142        polys_per_ssa: u16,
143        shares_per_poly: u8,
144        surplus_shares: u8,
145    ) -> Result<Self, InvalidPixParams> {
146        Self::try_new(polys_per_ssa, shares_per_poly, surplus_shares, S::PIX_SUITE)
147    }
148
149    /// The [`SsaGeneratorConfig`]'s dimensions under the curve suite of `S`.
150    ///
151    /// The Entry's only source of a [`PixParams`]: the dimensions and the surplus are properties of
152    /// the installed generator, and the suite is a property of the spec that generator is
153    /// instantiated over — neither is something a Session caller gets to pick.
154    ///
155    /// Fallible because [`SsaGeneratorConfig`]'s fields are public and its ranges are enforced by
156    /// `validator` rather than by construction.
157    pub fn try_from_config<S: crate::PixSpec>(cfg: &SsaGeneratorConfig) -> Result<Self, InvalidPixParams> {
158        Self::try_new_for::<S>(cfg.polynomials_per_ssa, cfg.threshold, cfg.surplus_shares)
159    }
160
161    /// Number of polynomials the SSA secret is split across.
162    #[inline]
163    pub const fn polys_per_ssa(&self) -> u16 {
164        self.polys_per_ssa
165    }
166
167    /// Shares required to reconstruct one polynomial.
168    #[inline]
169    pub const fn shares_per_poly(&self) -> u8 {
170        self.shares_per_poly
171    }
172
173    /// Shares emitted per polynomial beyond [`shares_per_poly`](Self::shares_per_poly), to absorb
174    /// losses.
175    #[inline]
176    pub const fn surplus_shares(&self) -> u8 {
177        self.surplus_shares
178    }
179
180    /// Total shares the Entry emits per polynomial, i.e. threshold plus surplus.
181    ///
182    /// Widened to `u16` because the sum of two `u8`s does not fit one.
183    #[inline]
184    pub const fn emitted_shares_per_poly(&self) -> u16 {
185        self.shares_per_poly as u16 + self.surplus_shares as u16
186    }
187
188    /// Useful shares needed to recover the whole SSA.
189    ///
190    /// A share count, unlike the byte quota those shares pay for, which the Session layer derives
191    /// from the same two fields. Excludes the surplus by construction: a surplus share is one that
192    /// arrives after its polynomial already has a full set, so it advances nothing.
193    #[inline]
194    pub const fn target_useful_shares(&self) -> u64 {
195        self.polys_per_ssa as u64 * self.shares_per_poly as u64
196    }
197
198    /// The elliptic curve suite these parameters were produced under.
199    #[inline]
200    pub const fn suite(&self) -> PixSuite {
201        self.suite
202    }
203
204    /// Packs into 32 bits: `suite` in bits 31..30, `polys_per_ssa` in bits 29..16,
205    /// `shares_per_poly` in bits 15..8, and `surplus_shares` in bits 7..0.
206    #[inline]
207    pub const fn to_u32(&self) -> u32 {
208        ((self.suite as u32) << SUITE_SHIFT)
209            | ((self.polys_per_ssa as u32 & POLYS_MASK) << POLYS_SHIFT)
210            | ((self.shares_per_poly as u32) << SHARES_SHIFT)
211            | self.surplus_shares as u32
212    }
213
214    /// Inverse of [`to_u32`](Self::to_u32), rejecting out-of-range values and unknown suites.
215    ///
216    /// # Compatibility with words packed before the suite existed
217    ///
218    /// Those words carried the polynomial count in the full top 16 bits, but the count is bounded by
219    /// [`MAX_POLYS_PER_SSA`] and so never set the two the suite now occupies. Reading such a word
220    /// therefore yields [`PixSuite::BabyJubJub`], which is what those builds ran by default. In the
221    /// other direction a peer that predates this field rejects a `Secp256k1` word outright: the
222    /// suite bit reads to it as a polynomial count of at least 16 384, above the maximum it already
223    /// enforced. Neither side mis-parses the other; both refuse.
224    pub const fn try_from_u32(packed: u32) -> Result<Self, InvalidPixParams> {
225        let suite = match PixSuite::try_from_bits((packed >> SUITE_SHIFT) as u8) {
226            Ok(suite) => suite,
227            Err(error) => return Err(error),
228        };
229        Self::try_new(
230            ((packed >> POLYS_SHIFT) & POLYS_MASK) as u16,
231            (packed >> SHARES_SHIFT) as u8,
232            packed as u8,
233            suite,
234        )
235    }
236
237    /// Packs into the upper half of the 64-bit options word carried in the most significant 64 bits
238    /// of `StartInitiation::additional_data`, leaving `surb_target` in the lower half of that word.
239    ///
240    /// The options word is fully allocated by these two halves; the least significant 64 bits of
241    /// `StartInitiation::additional_data` are currently unused and remain available for negotiating
242    /// anything further.
243    #[inline]
244    pub const fn into_additional_data(self, surb_target: u32) -> u64 {
245        ((self.to_u32() as u64) << ADDITIONAL_DATA_SHIFT) | surb_target as u64
246    }
247
248    /// Inverse of [`into_additional_data`](Self::into_additional_data), ignoring the SURB target in
249    /// the lower half of the options word.
250    pub const fn try_from_additional_data(additional_data: u64) -> Result<Self, InvalidPixParams> {
251        Self::try_from_u32((additional_data >> ADDITIONAL_DATA_SHIFT) as u32)
252    }
253}
254
255impl std::fmt::Display for PixParams {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        write!(
258            f,
259            "{} polys x {} shares (+{} surplus) on {}",
260            self.polys_per_ssa, self.shares_per_poly, self.surplus_shares, self.suite
261        )
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::{DEFAULT_POLY_THRESHOLD, DEFAULT_POLYS_PER_SSA, PixSpec, tests::TestSpec};
269
270    /// Pins the byte layout. A reshuffle of the fields is otherwise invisible: every value still
271    /// round-trips through `to_u32`/`try_from_u32`, and only the peer notices.
272    ///
273    /// The Baby JubJub word is the same one this test asserted before the suite existed, which is
274    /// the compatibility claim: adding the field moved no bit of a default-curve deployment.
275    #[test]
276    fn packed_layout_must_stay_suite_then_polys_then_threshold_then_surplus() -> anyhow::Result<()> {
277        let bjj = PixParams::try_new(0x1234, 0x56, 0x78, PixSuite::BabyJubJub)?;
278        assert_eq!(0x1234_5678, bjj.to_u32());
279        assert_eq!(0x1234_5678_9abc_def0_u64, bjj.into_additional_data(0x9abc_def0));
280
281        // secp256k1 differs only in bit 30.
282        let secp = PixParams::try_new(0x1234, 0x56, 0x78, PixSuite::Secp256k1)?;
283        assert_eq!(0x5234_5678, secp.to_u32());
284        assert_eq!(0x4000_0000, bjj.to_u32() ^ secp.to_u32());
285        Ok(())
286    }
287
288    #[test]
289    fn packed_u32_must_round_trip_over_the_whole_range() -> anyhow::Result<()> {
290        for suite in [PixSuite::BabyJubJub, PixSuite::Secp256k1] {
291            for polys in [1, 2, DEFAULT_POLYS_PER_SSA, MAX_POLYS_PER_SSA] {
292                for shares in [MIN_POLY_THRESHOLD, DEFAULT_POLY_THRESHOLD, MAX_POLY_THRESHOLD] {
293                    for surplus in [0, 1, 20, u8::MAX] {
294                        let params = PixParams::try_new(polys, shares, surplus, suite)?;
295                        assert_eq!(params, PixParams::try_from_u32(params.to_u32())?);
296                    }
297                }
298            }
299        }
300        Ok(())
301    }
302
303    #[test]
304    fn additional_data_must_round_trip_and_leave_the_surb_target_alone() -> anyhow::Result<()> {
305        for suite in [PixSuite::BabyJubJub, PixSuite::Secp256k1] {
306            let params = PixParams::try_new(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD, 32, suite)?;
307            for surb_target in [0, 1, 1234, u32::MAX] {
308                let additional_data = params.into_additional_data(surb_target);
309                assert_eq!(params, PixParams::try_from_additional_data(additional_data)?);
310                assert_eq!(surb_target, additional_data as u32);
311            }
312        }
313        Ok(())
314    }
315
316    #[test]
317    fn out_of_range_dimensions_must_be_rejected() {
318        assert_eq!(
319            Err(InvalidPixParams::PolysPerSsa(0)),
320            PixParams::try_new(0, DEFAULT_POLY_THRESHOLD, 0, PixSuite::BabyJubJub)
321        );
322        assert_eq!(
323            Err(InvalidPixParams::PolysPerSsa(MAX_POLYS_PER_SSA + 1)),
324            PixParams::try_new(MAX_POLYS_PER_SSA + 1, DEFAULT_POLY_THRESHOLD, 0, PixSuite::BabyJubJub)
325        );
326        for shares in [0, 1] {
327            assert_eq!(
328                Err(InvalidPixParams::SharesPerPoly(shares)),
329                PixParams::try_new(DEFAULT_POLYS_PER_SSA, shares, 0, PixSuite::BabyJubJub)
330            );
331        }
332    }
333
334    /// The decode side is the one that matters: these words arrive from a peer.
335    #[test]
336    fn out_of_range_packed_words_must_be_rejected() {
337        // polys = 0
338        assert!(PixParams::try_from_u32(0x0000_4020).is_err());
339        // threshold = 1
340        assert!(PixParams::try_from_u32(0x2000_0100).is_err());
341        // threshold = 0
342        assert!(PixParams::try_from_u32(0x2000_0020).is_err());
343        // Everything a `u8` surplus can say is legal.
344        assert!(PixParams::try_from_u32(0x2000_40ff).is_ok());
345    }
346
347    /// The two suite values no curve claims are refused, rather than read as a third curve.
348    #[test]
349    fn unknown_suite_identifiers_must_be_rejected() {
350        for (bits, word) in [(2u8, 0x8000_4020_u32), (3, 0xc000_4020)] {
351            assert_eq!(Err(InvalidPixParams::UnknownSuite(bits)), PixParams::try_from_u32(word));
352        }
353    }
354
355    /// What a peer built before the suite existed sees, and what it shows us.
356    ///
357    /// Both directions are refusals rather than mis-parses, which is the whole reason the suite went
358    /// into these two bits rather than into a new field.
359    #[test]
360    fn pre_suite_words_stay_readable_and_a_foreign_one_is_out_of_range() -> anyhow::Result<()> {
361        // A word packed before the field existed sets neither bit, so it reads as the curve those
362        // builds ran by default.
363        let pre_suite = ((DEFAULT_POLYS_PER_SSA as u32) << POLYS_SHIFT) | ((DEFAULT_POLY_THRESHOLD as u32) << 8) | 16;
364        let decoded = PixParams::try_from_u32(pre_suite)?;
365        assert_eq!(PixSuite::BabyJubJub, decoded.suite());
366        assert_eq!(DEFAULT_POLYS_PER_SSA, decoded.polys_per_ssa());
367
368        // And a secp256k1 word carries bit 30, which such a peer reads as a polynomial count of at
369        // least 16 384 — above the `MAX_POLYS_PER_SSA` it already enforced, so it refuses.
370        let secp = PixParams::try_new(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD, 16, PixSuite::Secp256k1)?.to_u32();
371        assert!(
372            (secp >> POLYS_SHIFT) as u16 > MAX_POLYS_PER_SSA,
373            "a pre-suite peer must reject a secp256k1 word by its existing polynomial range check"
374        );
375        Ok(())
376    }
377
378    #[test]
379    fn generator_config_must_convert() -> anyhow::Result<()> {
380        let cfg = SsaGeneratorConfig {
381            polynomials_per_ssa: 8,
382            threshold: 2,
383            surplus_shares: 3,
384        };
385        let params = PixParams::try_from_config::<TestSpec>(&cfg)?;
386        assert_eq!(8, params.polys_per_ssa());
387        assert_eq!(2, params.shares_per_poly());
388        assert_eq!(3, params.surplus_shares());
389        assert_eq!(5, params.emitted_shares_per_poly());
390        assert_eq!(
391            TestSpec::PIX_SUITE,
392            params.suite(),
393            "the announced suite must come from the spec that will generate the shares"
394        );
395
396        assert_eq!(
397            PixParams::try_from_config::<TestSpec>(&SsaGeneratorConfig::default())?,
398            PixParams::try_new(
399                DEFAULT_POLYS_PER_SSA,
400                DEFAULT_POLY_THRESHOLD,
401                SsaGeneratorConfig::default().surplus_shares,
402                TestSpec::PIX_SUITE
403            )?
404        );
405        Ok(())
406    }
407}