Skip to main content

subspace_verification/
lib.rs

1//! Verification primitives for Subspace.
2#![forbid(unsafe_code)]
3#![warn(rust_2018_idioms, missing_debug_implementations, missing_docs)]
4#![cfg_attr(feature = "kzg", feature(portable_simd))]
5#![cfg_attr(not(feature = "std"), no_std)]
6
7#[cfg(not(feature = "std"))]
8extern crate alloc;
9
10#[cfg(not(feature = "std"))]
11use alloc::string::String;
12#[cfg(all(feature = "kzg", not(feature = "std")))]
13use alloc::vec::Vec;
14use core::mem;
15#[cfg(feature = "kzg")]
16use core::num::NonZeroU64;
17#[cfg(feature = "kzg")]
18use core::simd::Simd;
19use parity_scale_codec::{Decode, Encode, MaxEncodedLen};
20use schnorrkel::SignatureError;
21use schnorrkel::context::SigningContext;
22#[cfg(feature = "kzg")]
23use subspace_core_primitives::hashes::blake3_254_hash_to_scalar;
24use subspace_core_primitives::hashes::{Blake3Hash, blake3_hash_list, blake3_hash_with_key};
25#[cfg(feature = "kzg")]
26use subspace_core_primitives::pieces::{PieceArray, Record, RecordWitness};
27use subspace_core_primitives::pot::PotOutput;
28#[cfg(feature = "kzg")]
29use subspace_core_primitives::sectors::SectorId;
30use subspace_core_primitives::sectors::SectorSlotChallenge;
31#[cfg(feature = "kzg")]
32use subspace_core_primitives::segments::ArchivedHistorySegment;
33use subspace_core_primitives::segments::{HistorySize, SegmentCommitment};
34#[cfg(feature = "kzg")]
35use subspace_core_primitives::solutions::Solution;
36use subspace_core_primitives::solutions::{RewardSignature, SolutionRange};
37use subspace_core_primitives::{BlockForkWeight, BlockNumber, PublicKey, ScalarBytes, SlotNumber};
38#[cfg(feature = "kzg")]
39use subspace_kzg::{Commitment, Kzg, Scalar, Witness};
40#[cfg(feature = "kzg")]
41use subspace_proof_of_space::Table;
42
43/// Errors encountered by the Subspace consensus primitives.
44#[derive(Debug, Eq, PartialEq, thiserror::Error)]
45pub enum Error {
46    /// Invalid piece offset
47    #[error("Piece verification failed")]
48    InvalidPieceOffset {
49        /// Index of the piece that failed verification
50        piece_offset: u16,
51        /// How many pieces one sector is supposed to contain (max)
52        max_pieces_in_sector: u16,
53    },
54    /// History size is in the future
55    #[error("History size {solution} is in the future, current is {current}")]
56    FutureHistorySize {
57        /// Current history size
58        current: HistorySize,
59        /// History size solution was created for
60        solution: HistorySize,
61    },
62    /// Sector expired
63    #[error("Sector expired")]
64    SectorExpired {
65        /// Expiration history size
66        expiration_history_size: HistorySize,
67        /// Current history size
68        current_history_size: HistorySize,
69    },
70    /// Piece verification failed
71    #[error("Piece verification failed")]
72    InvalidPiece,
73    /// Solution is outside of challenge range
74    #[error(
75        "Solution distance {solution_distance} is outside of solution range \
76        {half_solution_range} (half of actual solution range)"
77    )]
78    OutsideSolutionRange {
79        /// Half of solution range
80        half_solution_range: SolutionRange,
81        /// Solution distance
82        solution_distance: SolutionRange,
83    },
84    /// Invalid proof of space
85    #[error("Invalid proof of space")]
86    InvalidProofOfSpace,
87    /// Invalid audit chunk offset
88    #[error("Invalid audit chunk offset")]
89    InvalidAuditChunkOffset,
90    /// Invalid chunk
91    #[error("Invalid chunk: {0}")]
92    InvalidChunk(String),
93    /// Invalid chunk witness
94    #[error("Invalid chunk witness")]
95    InvalidChunkWitness,
96    /// Invalid history size
97    #[error("Invalid history size")]
98    InvalidHistorySize,
99}
100
101/// Check the reward signature validity.
102pub fn check_reward_signature(
103    hash: &[u8],
104    signature: &RewardSignature,
105    public_key: &PublicKey,
106    reward_signing_context: &SigningContext,
107) -> Result<(), SignatureError> {
108    let public_key = schnorrkel::PublicKey::from_bytes(public_key.as_ref())?;
109    let signature = schnorrkel::Signature::from_bytes(signature.as_ref())?;
110    public_key.verify(reward_signing_context.bytes(hash), &signature)
111}
112
113/// Calculates solution distance for given parameters, is used as a primitive to check whether
114/// solution distance is within solution range (see [`is_within_solution_range()`]).
115fn calculate_solution_distance(
116    global_challenge: &Blake3Hash,
117    chunk: &[u8; 32],
118    sector_slot_challenge: &SectorSlotChallenge,
119) -> SolutionRange {
120    let audit_chunk = blake3_hash_with_key(sector_slot_challenge, chunk);
121    let audit_chunk_as_solution_range: SolutionRange = SolutionRange::from_le_bytes(
122        *audit_chunk
123            .as_chunks::<{ mem::size_of::<SolutionRange>() }>()
124            .0
125            .iter()
126            .next()
127            .expect("Solution range is smaller in size than global challenge; qed"),
128    );
129    let global_challenge_as_solution_range: SolutionRange = SolutionRange::from_le_bytes(
130        *global_challenge
131            .as_chunks::<{ mem::size_of::<SolutionRange>() }>()
132            .0
133            .iter()
134            .next()
135            .expect("Solution range is smaller in size than global challenge; qed"),
136    );
137    subspace_core_primitives::solutions::bidirectional_distance(
138        &global_challenge_as_solution_range,
139        &audit_chunk_as_solution_range,
140    )
141}
142
143/// Returns `Some(solution_distance)` if solution distance is within the solution range for provided
144/// parameters.
145pub fn is_within_solution_range(
146    global_challenge: &Blake3Hash,
147    chunk: &[u8; 32],
148    sector_slot_challenge: &SectorSlotChallenge,
149    solution_range: SolutionRange,
150) -> Option<SolutionRange> {
151    let solution_distance =
152        calculate_solution_distance(global_challenge, chunk, sector_slot_challenge);
153    (solution_distance <= solution_range / 2).then_some(solution_distance)
154}
155
156/// Parameters for checking piece validity
157#[derive(Debug, Clone, Encode, Decode, MaxEncodedLen)]
158pub struct PieceCheckParams {
159    /// How many pieces one sector is supposed to contain (max)
160    pub max_pieces_in_sector: u16,
161    /// Segment commitment of segment to which piece belongs
162    pub segment_commitment: SegmentCommitment,
163    /// Number of latest archived segments that are considered "recent history"
164    pub recent_segments: HistorySize,
165    /// Fraction of pieces from the "recent history" (`recent_segments`) in each sector
166    pub recent_history_fraction: (HistorySize, HistorySize),
167    /// Minimum lifetime of a plotted sector, measured in archived segment
168    pub min_sector_lifetime: HistorySize,
169    /// Current size of the history
170    pub current_history_size: HistorySize,
171    /// Segment commitment at `min_sector_lifetime` from sector creation (if exists)
172    pub sector_expiration_check_segment_commitment: Option<SegmentCommitment>,
173}
174
175/// Parameters for solution verification
176#[derive(Debug, Clone, Encode, Decode, MaxEncodedLen)]
177pub struct VerifySolutionParams {
178    /// Proof of time for which solution is built
179    pub proof_of_time: PotOutput,
180    /// Solution range
181    pub solution_range: SolutionRange,
182    /// Parameters for checking piece validity.
183    ///
184    /// If `None`, piece validity check will be skipped.
185    pub piece_check_params: Option<PieceCheckParams>,
186}
187
188/// Calculate the block's contribution to the fork weight, which is derived from the provided
189/// solution range.
190pub fn calculate_block_fork_weight(solution_range: SolutionRange) -> BlockForkWeight {
191    // Work around the test runtime accepting all solutions, which causes blocks to have zero
192    // fork weight. This makes each node keep its own blocks, and never reorg to a common chain.
193    #[cfg(feature = "testing")]
194    let solution_range = if solution_range == SolutionRange::MAX {
195        SolutionRange::MAX - 1
196    } else {
197        solution_range
198    };
199
200    BlockForkWeight::from(SolutionRange::MAX - solution_range)
201}
202
203/// Verify whether solution is valid, returns solution distance that is `<= solution_range/2` on
204/// success.
205#[cfg(feature = "kzg")]
206pub fn verify_solution<'a, PosTable, RewardAddress>(
207    solution: &'a Solution<RewardAddress>,
208    slot: SlotNumber,
209    params: &'a VerifySolutionParams,
210    kzg: &'a Kzg,
211) -> Result<SolutionRange, Error>
212where
213    PosTable: Table,
214{
215    use subspace_core_primitives::solutions::SolutionPotVerifier;
216
217    let VerifySolutionParams {
218        proof_of_time,
219        solution_range,
220        piece_check_params,
221    } = params;
222
223    let sector_id = SectorId::new(
224        solution.public_key.hash(),
225        solution.sector_index,
226        solution.history_size,
227    );
228
229    let global_randomness = proof_of_time.derive_global_randomness();
230    let global_challenge = global_randomness.derive_global_challenge(slot);
231    let sector_slot_challenge = sector_id.derive_sector_slot_challenge(&global_challenge);
232    let s_bucket_audit_index = sector_slot_challenge.s_bucket_audit_index();
233
234    // Check that proof of space is valid
235    if !<PosTable as SolutionPotVerifier>::is_proof_valid(
236        &sector_id.derive_evaluation_seed(solution.piece_offset),
237        s_bucket_audit_index.into(),
238        &solution.proof_of_space,
239    ) {
240        return Err(Error::InvalidProofOfSpace);
241    };
242
243    let masked_chunk =
244        (Simd::from(*solution.chunk) ^ Simd::from(*solution.proof_of_space.hash())).to_array();
245
246    let solution_distance =
247        calculate_solution_distance(&global_challenge, &masked_chunk, &sector_slot_challenge);
248
249    // Check that solution is within solution range
250    if solution_distance > solution_range / 2 {
251        return Err(Error::OutsideSolutionRange {
252            half_solution_range: solution_range / 2,
253            solution_distance,
254        });
255    }
256
257    // Check that chunk belongs to the record
258    if !kzg.verify(
259        &Commitment::try_from(solution.record_commitment)
260            .map_err(|_error| Error::InvalidChunkWitness)?,
261        Record::NUM_S_BUCKETS,
262        s_bucket_audit_index.into(),
263        &Scalar::try_from(solution.chunk).map_err(Error::InvalidChunk)?,
264        &Witness::try_from(solution.chunk_witness).map_err(|_error| Error::InvalidChunkWitness)?,
265    ) {
266        return Err(Error::InvalidChunkWitness);
267    }
268
269    if let Some(PieceCheckParams {
270        max_pieces_in_sector,
271        segment_commitment,
272        recent_segments,
273        recent_history_fraction,
274        min_sector_lifetime,
275        current_history_size,
276        sector_expiration_check_segment_commitment,
277    }) = piece_check_params
278    {
279        // `+1` here is due to the possibility of plotting a sector that was just archived and whose
280        // segment root is just being included in this very block we're checking (parent block,
281        // which is where `current_history_size` comes from doesn't know about this block yet)
282        if NonZeroU64::from(solution.history_size).get()
283            > NonZeroU64::from(*current_history_size).get() + 1
284        {
285            return Err(Error::FutureHistorySize {
286                current: *current_history_size,
287                solution: solution.history_size,
288            });
289        }
290
291        if u16::from(solution.piece_offset) >= *max_pieces_in_sector {
292            return Err(Error::InvalidPieceOffset {
293                piece_offset: u16::from(solution.piece_offset),
294                max_pieces_in_sector: *max_pieces_in_sector,
295            });
296        }
297
298        if let Some(sector_expiration_check_segment_commitment) =
299            sector_expiration_check_segment_commitment
300        {
301            let expiration_history_size = match sector_id.derive_expiration_history_size(
302                solution.history_size,
303                sector_expiration_check_segment_commitment,
304                *min_sector_lifetime,
305            ) {
306                Some(expiration_history_size) => expiration_history_size,
307                None => {
308                    return Err(Error::InvalidHistorySize);
309                }
310            };
311
312            if expiration_history_size <= *current_history_size {
313                return Err(Error::SectorExpired {
314                    expiration_history_size,
315                    current_history_size: *current_history_size,
316                });
317            }
318        }
319
320        let position = sector_id
321            .derive_piece_index(
322                solution.piece_offset,
323                solution.history_size,
324                *max_pieces_in_sector,
325                *recent_segments,
326                *recent_history_fraction,
327            )
328            .position();
329
330        // Check that piece is part of the blockchain history
331        if !is_record_commitment_hash_valid(
332            kzg,
333            &Scalar::try_from(blake3_254_hash_to_scalar(
334                solution.record_commitment.as_ref(),
335            ))
336            .expect("Create correctly by dedicated hash function; qed"),
337            segment_commitment,
338            &solution.record_witness,
339            position,
340        ) {
341            return Err(Error::InvalidPiece);
342        }
343    }
344
345    Ok(solution_distance)
346}
347
348/// Validate witness embedded within a piece produced by archiver
349#[cfg(feature = "kzg")]
350pub fn is_piece_valid(
351    kzg: &Kzg,
352    piece: &PieceArray,
353    segment_commitment: &SegmentCommitment,
354    position: u32,
355) -> bool {
356    let (record, commitment, witness) = piece.split();
357    let witness = match Witness::try_from_bytes(witness) {
358        Ok(witness) => witness,
359        _ => {
360            return false;
361        }
362    };
363
364    let mut scalars = Vec::with_capacity(record.len().next_power_of_two());
365
366    for record_chunk in record.iter() {
367        match Scalar::try_from(record_chunk) {
368            Ok(scalar) => {
369                scalars.push(scalar);
370            }
371            _ => {
372                return false;
373            }
374        }
375    }
376
377    // Number of scalars for KZG must be a power of two elements
378    scalars.resize(scalars.capacity(), Scalar::default());
379
380    let polynomial = match kzg.poly(&scalars) {
381        Ok(polynomial) => polynomial,
382        _ => {
383            return false;
384        }
385    };
386
387    if kzg
388        .commit(&polynomial)
389        .map(|commitment| commitment.to_bytes())
390        .as_ref()
391        != Ok(commitment)
392    {
393        return false;
394    }
395
396    let Ok(segment_commitment) = Commitment::try_from(segment_commitment) else {
397        return false;
398    };
399
400    let commitment_hash = Scalar::try_from(blake3_254_hash_to_scalar(commitment.as_ref()))
401        .expect("Create correctly by dedicated hash function; qed");
402
403    kzg.verify(
404        &segment_commitment,
405        ArchivedHistorySegment::NUM_PIECES,
406        position,
407        &commitment_hash,
408        &witness,
409    )
410}
411
412/// Validate witness for record commitment hash produced by archiver
413#[cfg(feature = "kzg")]
414pub fn is_record_commitment_hash_valid(
415    kzg: &Kzg,
416    record_commitment_hash: &Scalar,
417    commitment: &SegmentCommitment,
418    witness: &RecordWitness,
419    position: u32,
420) -> bool {
421    let Ok(commitment) = Commitment::try_from(commitment) else {
422        return false;
423    };
424    let Ok(witness) = Witness::try_from(witness) else {
425        return false;
426    };
427
428    kzg.verify(
429        &commitment,
430        ArchivedHistorySegment::NUM_PIECES,
431        position,
432        record_commitment_hash,
433        &witness,
434    )
435}
436
437/// Derive proof of time entropy from chunk and proof of time for injection purposes.
438#[inline]
439pub fn derive_pot_entropy(chunk: &ScalarBytes, proof_of_time: PotOutput) -> Blake3Hash {
440    blake3_hash_list(&[chunk.as_ref(), proof_of_time.as_ref()])
441}
442
443/// Derives next solution range based on the total era slots and slot probability
444pub fn derive_next_solution_range(
445    start_slot: SlotNumber,
446    current_slot: SlotNumber,
447    slot_probability: (u64, u64),
448    current_solution_range: SolutionRange,
449    era_duration: BlockNumber,
450) -> u64 {
451    // calculate total slots within this era
452    let era_slot_count = current_slot - start_slot;
453
454    // Now we need to re-calculate solution range. The idea here is to keep block production at
455    // the same pace while space pledged on the network changes. For this we adjust previous
456    // solution range according to actual and expected number of blocks per era.
457
458    // Below is code analogous to the following, but without using floats:
459    // ```rust
460    // let actual_slots_per_block = era_slot_count as f64 / era_duration as f64;
461    // let expected_slots_per_block =
462    //     slot_probability.1 as f64 / slot_probability.0 as f64;
463    // let adjustment_factor =
464    //     (actual_slots_per_block / expected_slots_per_block).clamp(0.25, 4.0);
465    //
466    // next_solution_range =
467    //     (solution_ranges.current as f64 * adjustment_factor).round() as u64;
468    // ```
469    u64::try_from(
470        u128::from(current_solution_range)
471            .saturating_mul(u128::from(era_slot_count))
472            .saturating_mul(u128::from(slot_probability.0))
473            / u128::from(era_duration)
474            / u128::from(slot_probability.1),
475    )
476    .unwrap_or(u64::MAX)
477    .clamp(
478        current_solution_range / 4,
479        current_solution_range.saturating_mul(4),
480    )
481}