Skip to main content

spongefish_pow/
transcript.rs

1//! Verifier messages with grinding.
2
3use spongefish::{
4    DuplexSpongeInit, DuplexSpongeInterface, Encoding, FromNarg, FromUniform, ProverState,
5    Transcript, VerificationError, Witness,
6};
7
8/// The configuration for a proof of work.
9///
10/// A [`PowStrategy`] contains the parameters for the proof of work.
11/// Parameters and acceptance must remain equivalent across calls and clones.
12///
13/// # Security
14///
15/// When work is requested, the grinding is expected to amplify soundness for
16/// the **current round**. In particular, trait implementors must be wary of preprocessing
17/// attacks that might occur when seeding the PoW with a fixed seed, or creating inadvertly
18/// a new round.
19///
20/// Failed nonce checks must use [`Transcript::check`] to permanently reject the
21/// verifier.
22///
23/// The difficulty, placement and solution codec must be
24/// fixed by the protocol and properly accounted for in the session identifier.
25///
26/// The helper absorbs the nonce before deriving `challenge_len()` verifier bytes.
27pub trait PowStrategy: Clone {
28    /// The canonical, self-delimiting nonce codec. Integers use fixed-width encoding.
29    ///
30    /// Conversion to and from `u64` must round-trip every permitted nonce.
31    type Nonce: From<u64> + Into<u64> + Encoding + FromNarg;
32
33    /// Whether to exchange a nonce and derive check bytes.
34    ///
35    /// Returning `false` skips all work and immediately derives the protected message.
36    fn enabled(&self) -> bool {
37        true
38    }
39
40    /// The inclusive nonce bound, starting at zero.
41    fn last_nonce(&self) -> u64 {
42        u64::MAX
43    }
44
45    /// Number of verifier bytes derived after each candidate nonce.
46    ///
47    /// This must be positive when work is enabled and fixed by the protocol.
48    fn challenge_len(&self) -> usize;
49
50    /// Test the nonce and its verifier bytes without changing the puzzle.
51    /// Acceptance must not depend on evaluation order.
52    /// `challenge` must contain exactly [`Self::challenge_len`] bytes.
53    ///
54    /// For seeded PoW these bytes are the seed, derived anew for every nonce.
55    /// This predicate must enforce the protocol's difficulty; it cannot establish
56    /// a soundness claim by itself.
57    fn accepts(&self, nonce: u64, challenge: &[u8]) -> bool;
58
59    /// Return the least accepted nonce in `0..=last_nonce()`, or `None` on exhaustion.
60    ///
61    /// Synchronous and potentially blocking. Implementations must not panic for
62    /// valid strategy parameters and a non-panicking evaluator.
63    /// `evaluate` must fill its output with verifier bytes after the encoded nonce;
64    /// calls and clones must agree. It must not advance the live transcript.
65    fn solve<E: Fn(&[u8], &mut [u8]) + Clone>(self, evaluate: E) -> Option<u64> {
66        let mut challenge = vec![0; self.challenge_len()];
67        (0..=self.last_nonce()).find(|&nonce| {
68            evaluate(Self::Nonce::from(nonce).encode().as_ref(), &mut challenge);
69            self.accepts(nonce, &challenge)
70        })
71    }
72
73    /// Return the same least nonce as [`Self::solve`], or `None` on exhaustion.
74    ///
75    /// Synchronous and potentially blocking.
76    /// Default implementations use Rayon with `parallel`
77    /// and otherwise calls [`Self::solve`].
78    /// Parallelism may reduce elapsed time but can evaluate extra candidates;
79    /// speedup is not guaranteed.
80    /// Only strategy and evaluator state require `Send`, without `Sync`.
81    /// Overrides must not panic for valid parameters and a non-panicking evaluator.
82    /// Callback panics propagate; Rayon may panic if pool initialization fails.
83    fn solve_parallel<E>(self, evaluate: E) -> Option<u64>
84    where
85        Self: Send,
86        E: Fn(&[u8], &mut [u8]) + Clone + Send,
87    {
88        #[cfg(feature = "parallel")]
89        {
90            let challenge = vec![0; self.challenge_len()];
91            let nonces = 0..=self.last_nonce();
92            search_parallel(
93                (self, evaluate, challenge),
94                nonces,
95                |(config, evaluate, challenge), nonce| {
96                    evaluate(Self::Nonce::from(nonce).encode().as_ref(), challenge);
97                    config.accepts(nonce, challenge)
98                },
99            )
100        }
101        #[cfg(not(feature = "parallel"))]
102        {
103            self.solve(evaluate)
104        }
105    }
106}
107
108/// Obtain verifier messages protected by a configured proof-of-work protocol.
109///
110/// Available both inside generic [`Argument::run`](spongefish::Argument::run)
111/// implementations and directly on prover and verifier states. The helper owns
112/// nonce exchange, binding and verification; callers never supply a seed.
113///
114/// ```
115/// use spongefish::{Transcript, VerificationError};
116/// use spongefish_pow::{PowStrategy, PowTranscriptExt};
117///
118/// fn round<T: Transcript>(
119///     transcript: &mut T,
120///     config: impl PowStrategy,
121/// ) -> Result<u64, VerificationError> {
122///     transcript.verifier_message_pow(config)
123/// }
124/// ```
125pub trait PowTranscriptExt: Transcript {
126    /// Search sequentially, exchange the nonce, check it and return the protected message.
127    fn verifier_message_pow<T: FromUniform>(
128        &mut self,
129        config: impl PowStrategy,
130    ) -> Result<T, VerificationError> {
131        if config.enabled() && config.challenge_len() == 0 {
132            self.check(|| false)?;
133            return Err(VerificationError);
134        }
135        let nonce = if config.enabled() {
136            self.prover_only(|| {
137                let evaluate = self
138                    .verifier_message_oracle()
139                    .into_known()
140                    .ok_or(VerificationError)?;
141                config.clone().solve(evaluate).ok_or(VerificationError)
142            })
143            .transpose()?
144        } else {
145            Witness::unknown()
146        };
147        finish(self, &config, nonce)
148    }
149}
150
151impl<T: Transcript + ?Sized> PowTranscriptExt for T {}
152
153/// Search in parallel on a concrete prover and return the protected verifier message.
154///
155/// Uses the same nonce exchange, check and protected message as
156/// [`PowTranscriptExt::verifier_message_pow`]. The verifier uses that ordinary
157/// helper and never searches.
158///
159/// Only the public sponge and configuration need `Send`; the live prover and
160/// private RNG stay on the calling thread and need neither `Send` nor `Sync`.
161/// This function is synchronous and may block. It is available without Rayon:
162/// the default solver falls back to sequential search unless `parallel` is enabled.
163/// Configurations may override [`PowStrategy::solve_parallel`] for another backend.
164pub fn verifier_message_pow_parallel<T: FromUniform>(
165    prover: &mut ProverState<
166        impl DuplexSpongeInterface<U = u8> + Send,
167        impl DuplexSpongeInit<U = u8>,
168    >,
169    config: impl PowStrategy + Send,
170) -> Result<T, VerificationError> {
171    if !config.enabled() {
172        return finish(prover, &config, Witness::unknown());
173    }
174    if config.challenge_len() == 0 {
175        prover.check(|| false)?;
176        return Err(VerificationError);
177    }
178    let replay = config.clone();
179    let nonce = config
180        .solve_parallel(prover.verifier_message_oracle())
181        .ok_or(VerificationError)?;
182    finish(prover, &replay, Witness::known(nonce))
183}
184
185fn finish<T: FromUniform, C: PowStrategy>(
186    transcript: &mut (impl Transcript + ?Sized),
187    config: &C,
188    nonce: Witness<u64>,
189) -> Result<T, VerificationError> {
190    if config.enabled() {
191        let value: u64 = transcript.prover_message(nonce.map(C::Nonce::from))?.into();
192        let in_range = value <= config.last_nonce();
193        if nonce.map(|_| in_range).into_known() == Some(false) {
194            return Err(VerificationError);
195        }
196        transcript.check(|| in_range)?;
197        let challenge: Vec<u8> = (0..config.challenge_len())
198            .map(|_| transcript.verifier_message())
199            .collect();
200        let accepted = config.accepts(value, &challenge);
201        // ProverState::check may skip predicates in release builds. Reject a
202        // disagreement between snapshot search and live replay in either build.
203        if nonce.map(|_| accepted).into_known() == Some(false) {
204            return Err(VerificationError);
205        }
206        transcript.check(|| accepted)?;
207    } else {
208        transcript.check(|| true)?;
209    }
210    Ok(transcript.verifier_message())
211}
212
213// Search an ordered range with independent owned state per Rayon job.
214#[cfg(feature = "parallel")]
215fn search_parallel<S: Clone + Send>(
216    state: S,
217    nonces: core::ops::RangeInclusive<u64>,
218    check: fn(&mut S, u64) -> bool,
219) -> Option<u64> {
220    use rayon::prelude::*;
221
222    nonces
223        .into_par_iter()
224        .map_with(state, |state, nonce| check(state, nonce).then_some(nonce))
225        .find_first(Option::is_some)
226        .flatten()
227}
228
229#[cfg(test)]
230mod tests {
231    use spongefish::{
232        instantiations::TurboShake128, DuplexSpongeInit, DuplexSpongeInterface, Narg, ProverState,
233        VerifierState,
234    };
235
236    use super::{verifier_message_pow_parallel, PowStrategy, PowTranscriptExt};
237
238    // A small seeded puzzle for testing the shared driver, not a shipped construction.
239    #[derive(Clone)]
240    struct Seeded(bool);
241
242    fn hash_accepts(seed: &[u8], nonce: u64) -> bool {
243        let mut hash = TurboShake128::init(&[19; 32]);
244        hash.absorb(seed).absorb(&nonce.to_le_bytes());
245        let mut result = [0; 1];
246        hash.squeeze(&mut result);
247        result[0].trailing_zeros() >= 3
248    }
249
250    impl PowStrategy for Seeded {
251        type Nonce = u64;
252
253        fn enabled(&self) -> bool {
254            self.0
255        }
256        fn last_nonce(&self) -> u64 {
257            511
258        }
259        fn challenge_len(&self) -> usize {
260            32
261        }
262        fn accepts(&self, nonce: u64, seed: &[u8]) -> bool {
263            hash_accepts(seed, nonce)
264        }
265    }
266
267    #[test]
268    fn seeded_round_binds_each_counter_before_its_seed_and_checks_before_challenge() {
269        let session = Narg::derive_session_id(b"pow/nonce-first-seeded/v1");
270        for enabled in [false, true] {
271            let mut before = TurboShake128::init(session.as_bytes());
272            before.absorb(&0u32.to_le_bytes()).absorb(&[42]);
273            let candidate = |nonce: u64| {
274                let mut after = before.clone();
275                after.absorb(&nonce.to_le_bytes());
276                let mut seed = [0; 32];
277                after.squeeze(&mut seed);
278                (hash_accepts(&seed, nonce), after)
279            };
280            let (proof, mut after) = if enabled {
281                (0..=511)
282                    .find_map(|nonce| {
283                        let (accepted, after) = candidate(nonce);
284                        accepted.then_some((nonce.to_le_bytes().to_vec(), after))
285                    })
286                    .unwrap()
287            } else {
288                (vec![], before.clone())
289            };
290            let challenge = u64::from_le_bytes(after.squeeze_array());
291            let following = u64::from_le_bytes(after.squeeze_array());
292            for parallel in [false, true] {
293                let mut prover =
294                    ProverState::<TurboShake128>::new_with_seed(&session, &0u32, [7; 32]);
295                prover.public_message(&42u8);
296                let result = if parallel {
297                    verifier_message_pow_parallel::<u64>(&mut prover, Seeded(enabled))
298                } else {
299                    prover.verifier_message_pow::<u64>(Seeded(enabled))
300                };
301                assert_eq!(result.unwrap(), challenge);
302                assert_eq!(prover.verifier_message::<u64>(), following);
303                assert_eq!(prover.into_narg_string(), proof);
304                let mut verifier = VerifierState::<TurboShake128>::new(&session, &0u32, &proof);
305                verifier.public_message(&42u8);
306                let result = verifier.verifier_message_pow::<u64>(Seeded(enabled));
307                assert_eq!(result.unwrap(), challenge);
308                assert_eq!(verifier.verifier_message::<u64>(), following);
309                verifier.check_eof().unwrap();
310            }
311            if enabled {
312                let bad = (0..=511).find(|&n| !candidate(n).0).unwrap().to_le_bytes();
313                let out_of_range = 512u64.to_le_bytes();
314                for proof in [&bad[..], &out_of_range[..], &bad[..7]] {
315                    let mut verifier = VerifierState::<TurboShake128>::new(&session, &0u32, proof);
316                    verifier.public_message(&42u8);
317                    assert!(verifier.verifier_message_pow::<u64>(Seeded(true)).is_err());
318                    assert!(verifier.verifier_message_pow::<u64>(Seeded(false)).is_err());
319                    assert!(verifier.check_eof().is_err());
320                }
321            }
322        }
323    }
324
325    #[derive(Clone)]
326    struct Problem {
327        limit: u64,
328        accepted: &'static [u64],
329    }
330
331    impl PowStrategy for Problem {
332        type Nonce = u64;
333
334        fn challenge_len(&self) -> usize {
335            1
336        }
337
338        fn accepts(&self, nonce: u64, _challenge: &[u8]) -> bool {
339            self.accepted.contains(&nonce)
340        }
341
342        fn last_nonce(&self) -> u64 {
343            self.limit
344        }
345    }
346
347    fn evaluate(_candidate: &[u8], output: &mut [u8]) {
348        output.fill(0);
349    }
350
351    #[test]
352    fn search_respects_nonce_order_and_bounds() {
353        const CASES: &[(u64, &[u64])] = &[
354            (0, &[]),
355            (0, &[0]),
356            (0, &[1]),
357            (8, &[]),
358            (8, &[0, 4, 8]),
359            (8, &[7, 1, 4]),
360            (8, &[8]),
361            (8, &[9, u64::MAX]),
362        ];
363        for &(limit, accepted) in CASES {
364            let problem = Problem { limit, accepted };
365            let expected = accepted.iter().copied().filter(|&n| n <= limit).min();
366            assert_eq!(problem.clone().solve(evaluate), expected);
367            #[cfg(not(feature = "parallel"))]
368            assert_eq!(problem.solve_parallel(evaluate), expected);
369            #[cfg(all(feature = "parallel", not(target_arch = "wasm32")))]
370            for workers in [1, 3, 4] {
371                let pool = rayon::ThreadPoolBuilder::new()
372                    .num_threads(workers)
373                    .build()
374                    .unwrap();
375                assert_eq!(
376                    pool.install(|| problem.clone().solve_parallel(evaluate)),
377                    expected
378                );
379            }
380        }
381    }
382
383    #[cfg(all(feature = "parallel", not(target_arch = "wasm32")))]
384    #[test]
385    fn parallel_search_handles_empty_ranges_and_u64_max() {
386        const HITS: &[&[u64]] = &[
387            &[],
388            &[0],
389            &[8],
390            &[9],
391            &[u64::MAX - 8],
392            &[u64::MAX - 1, u64::MAX],
393            &[u64::MAX],
394        ];
395        for workers in [1, 3, 4] {
396            let pool = rayon::ThreadPoolBuilder::new()
397                .num_threads(workers)
398                .build()
399                .unwrap();
400            for nonces in [
401                0..=8,
402                u64::MAX - 8..=u64::MAX,
403                core::ops::RangeInclusive::new(1, 0),
404            ] {
405                for &accepted in HITS {
406                    let problem = Problem {
407                        limit: *nonces.end(),
408                        accepted,
409                    };
410                    let expected = accepted
411                        .iter()
412                        .copied()
413                        .filter(|n| nonces.contains(n))
414                        .min();
415                    assert_eq!(
416                        pool.install(|| super::search_parallel(
417                            problem,
418                            nonces.clone(),
419                            |problem, nonce| problem.accepts(nonce, &[0])
420                        )),
421                        expected
422                    );
423                }
424            }
425        }
426    }
427}