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}