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 upper half 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 /// The elliptic curve suite these parameters were produced under.
189 #[inline]
190 pub const fn suite(&self) -> PixSuite {
191 self.suite
192 }
193
194 /// Packs into 32 bits: `suite` in bits 31..30, `polys_per_ssa` in bits 29..16,
195 /// `shares_per_poly` in bits 15..8, and `surplus_shares` in bits 7..0.
196 #[inline]
197 pub const fn to_u32(&self) -> u32 {
198 ((self.suite as u32) << SUITE_SHIFT)
199 | ((self.polys_per_ssa as u32 & POLYS_MASK) << POLYS_SHIFT)
200 | ((self.shares_per_poly as u32) << SHARES_SHIFT)
201 | self.surplus_shares as u32
202 }
203
204 /// Inverse of [`to_u32`](Self::to_u32), rejecting out-of-range values and unknown suites.
205 ///
206 /// # Compatibility with words packed before the suite existed
207 ///
208 /// Those words carried the polynomial count in the full top 16 bits, but the count is bounded by
209 /// [`MAX_POLYS_PER_SSA`] and so never set the two the suite now occupies. Reading such a word
210 /// therefore yields [`PixSuite::BabyJubJub`], which is what those builds ran by default. In the
211 /// other direction a peer that predates this field rejects a `Secp256k1` word outright: the
212 /// suite bit reads to it as a polynomial count of at least 16 384, above the maximum it already
213 /// enforced. Neither side mis-parses the other; both refuse.
214 pub const fn try_from_u32(packed: u32) -> Result<Self, InvalidPixParams> {
215 let suite = match PixSuite::try_from_bits((packed >> SUITE_SHIFT) as u8) {
216 Ok(suite) => suite,
217 Err(error) => return Err(error),
218 };
219 Self::try_new(
220 ((packed >> POLYS_SHIFT) & POLYS_MASK) as u16,
221 (packed >> SHARES_SHIFT) as u8,
222 packed as u8,
223 suite,
224 )
225 }
226
227 /// Packs into the upper half of a `StartInitiation::additional_data` word, leaving `surb_target`
228 /// in the lower half.
229 ///
230 /// The two halves are the whole of that field: there is no room left in it to negotiate anything
231 /// further.
232 #[inline]
233 pub const fn into_additional_data(self, surb_target: u32) -> u64 {
234 ((self.to_u32() as u64) << ADDITIONAL_DATA_SHIFT) | surb_target as u64
235 }
236
237 /// Inverse of [`into_additional_data`](Self::into_additional_data), ignoring the SURB target in
238 /// the lower half.
239 pub const fn try_from_additional_data(additional_data: u64) -> Result<Self, InvalidPixParams> {
240 Self::try_from_u32((additional_data >> ADDITIONAL_DATA_SHIFT) as u32)
241 }
242}
243
244impl std::fmt::Display for PixParams {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 write!(
247 f,
248 "{} polys x {} shares (+{} surplus) on {}",
249 self.polys_per_ssa, self.shares_per_poly, self.surplus_shares, self.suite
250 )
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::{DEFAULT_POLY_THRESHOLD, DEFAULT_POLYS_PER_SSA, PixSpec, tests::TestSpec};
258
259 /// Pins the byte layout. A reshuffle of the fields is otherwise invisible: every value still
260 /// round-trips through `to_u32`/`try_from_u32`, and only the peer notices.
261 ///
262 /// The Baby JubJub word is the same one this test asserted before the suite existed, which is
263 /// the compatibility claim: adding the field moved no bit of a default-curve deployment.
264 #[test]
265 fn packed_layout_must_stay_suite_then_polys_then_threshold_then_surplus() -> anyhow::Result<()> {
266 let bjj = PixParams::try_new(0x1234, 0x56, 0x78, PixSuite::BabyJubJub)?;
267 assert_eq!(0x1234_5678, bjj.to_u32());
268 assert_eq!(0x1234_5678_9abc_def0_u64, bjj.into_additional_data(0x9abc_def0));
269
270 // secp256k1 differs only in bit 30.
271 let secp = PixParams::try_new(0x1234, 0x56, 0x78, PixSuite::Secp256k1)?;
272 assert_eq!(0x5234_5678, secp.to_u32());
273 assert_eq!(0x4000_0000, bjj.to_u32() ^ secp.to_u32());
274 Ok(())
275 }
276
277 #[test]
278 fn packed_u32_must_round_trip_over_the_whole_range() -> anyhow::Result<()> {
279 for suite in [PixSuite::BabyJubJub, PixSuite::Secp256k1] {
280 for polys in [1, 2, DEFAULT_POLYS_PER_SSA, MAX_POLYS_PER_SSA] {
281 for shares in [MIN_POLY_THRESHOLD, DEFAULT_POLY_THRESHOLD, MAX_POLY_THRESHOLD] {
282 for surplus in [0, 1, 20, u8::MAX] {
283 let params = PixParams::try_new(polys, shares, surplus, suite)?;
284 assert_eq!(params, PixParams::try_from_u32(params.to_u32())?);
285 }
286 }
287 }
288 }
289 Ok(())
290 }
291
292 #[test]
293 fn additional_data_must_round_trip_and_leave_the_surb_target_alone() -> anyhow::Result<()> {
294 for suite in [PixSuite::BabyJubJub, PixSuite::Secp256k1] {
295 let params = PixParams::try_new(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD, 32, suite)?;
296 for surb_target in [0, 1, 1234, u32::MAX] {
297 let additional_data = params.into_additional_data(surb_target);
298 assert_eq!(params, PixParams::try_from_additional_data(additional_data)?);
299 assert_eq!(surb_target, additional_data as u32);
300 }
301 }
302 Ok(())
303 }
304
305 #[test]
306 fn out_of_range_dimensions_must_be_rejected() {
307 assert_eq!(
308 Err(InvalidPixParams::PolysPerSsa(0)),
309 PixParams::try_new(0, DEFAULT_POLY_THRESHOLD, 0, PixSuite::BabyJubJub)
310 );
311 assert_eq!(
312 Err(InvalidPixParams::PolysPerSsa(MAX_POLYS_PER_SSA + 1)),
313 PixParams::try_new(MAX_POLYS_PER_SSA + 1, DEFAULT_POLY_THRESHOLD, 0, PixSuite::BabyJubJub)
314 );
315 for shares in [0, 1] {
316 assert_eq!(
317 Err(InvalidPixParams::SharesPerPoly(shares)),
318 PixParams::try_new(DEFAULT_POLYS_PER_SSA, shares, 0, PixSuite::BabyJubJub)
319 );
320 }
321 }
322
323 /// The decode side is the one that matters: these words arrive from a peer.
324 #[test]
325 fn out_of_range_packed_words_must_be_rejected() {
326 // polys = 0
327 assert!(PixParams::try_from_u32(0x0000_4020).is_err());
328 // threshold = 1
329 assert!(PixParams::try_from_u32(0x2000_0100).is_err());
330 // threshold = 0
331 assert!(PixParams::try_from_u32(0x2000_0020).is_err());
332 // Everything a `u8` surplus can say is legal.
333 assert!(PixParams::try_from_u32(0x2000_40ff).is_ok());
334 }
335
336 /// The two suite values no curve claims are refused, rather than read as a third curve.
337 #[test]
338 fn unknown_suite_identifiers_must_be_rejected() {
339 for (bits, word) in [(2u8, 0x8000_4020_u32), (3, 0xc000_4020)] {
340 assert_eq!(Err(InvalidPixParams::UnknownSuite(bits)), PixParams::try_from_u32(word));
341 }
342 }
343
344 /// What a peer built before the suite existed sees, and what it shows us.
345 ///
346 /// Both directions are refusals rather than mis-parses, which is the whole reason the suite went
347 /// into these two bits rather than into a new field.
348 #[test]
349 fn pre_suite_words_stay_readable_and_a_foreign_one_is_out_of_range() -> anyhow::Result<()> {
350 // A word packed before the field existed sets neither bit, so it reads as the curve those
351 // builds ran by default.
352 let pre_suite = ((DEFAULT_POLYS_PER_SSA as u32) << POLYS_SHIFT) | ((DEFAULT_POLY_THRESHOLD as u32) << 8) | 16;
353 let decoded = PixParams::try_from_u32(pre_suite)?;
354 assert_eq!(PixSuite::BabyJubJub, decoded.suite());
355 assert_eq!(DEFAULT_POLYS_PER_SSA, decoded.polys_per_ssa());
356
357 // And a secp256k1 word carries bit 30, which such a peer reads as a polynomial count of at
358 // least 16 384 — above the `MAX_POLYS_PER_SSA` it already enforced, so it refuses.
359 let secp = PixParams::try_new(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD, 16, PixSuite::Secp256k1)?.to_u32();
360 assert!(
361 (secp >> POLYS_SHIFT) as u16 > MAX_POLYS_PER_SSA,
362 "a pre-suite peer must reject a secp256k1 word by its existing polynomial range check"
363 );
364 Ok(())
365 }
366
367 #[test]
368 fn generator_config_must_convert() -> anyhow::Result<()> {
369 let cfg = SsaGeneratorConfig {
370 polynomials_per_ssa: 8,
371 threshold: 2,
372 surplus_shares: 3,
373 };
374 let params = PixParams::try_from_config::<TestSpec>(&cfg)?;
375 assert_eq!(8, params.polys_per_ssa());
376 assert_eq!(2, params.shares_per_poly());
377 assert_eq!(3, params.surplus_shares());
378 assert_eq!(5, params.emitted_shares_per_poly());
379 assert_eq!(
380 TestSpec::PIX_SUITE,
381 params.suite(),
382 "the announced suite must come from the spec that will generate the shares"
383 );
384
385 assert_eq!(
386 PixParams::try_from_config::<TestSpec>(&SsaGeneratorConfig::default())?,
387 PixParams::try_new(
388 DEFAULT_POLYS_PER_SSA,
389 DEFAULT_POLY_THRESHOLD,
390 SsaGeneratorConfig::default().surplus_shares,
391 TestSpec::PIX_SUITE
392 )?
393 );
394 Ok(())
395 }
396}