Skip to main content

hotshot_new_protocol/vid/
reconstruct.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashSet, btree_map::Entry},
3    ops::Range,
4};
5
6use committable::Commitment;
7use hotshot::traits::BlockPayload;
8use hotshot_types::{
9    data::{EpochNumber, VidCommitment2, VidDisperseShare2, ViewNumber, ns_table::parse_ns_table},
10    traits::{block_contents::EncodeBytes, node_implementation::NodeType},
11    vid::avidm_gf2::{AvidmGf2Common, AvidmGf2Param, AvidmGf2Scheme, AvidmGf2Share},
12};
13use tokio::task::{AbortHandle, JoinSet};
14use tracing::warn;
15
16type Metadata<T> = <<T as NodeType>::BlockPayload as BlockPayload<T>>::Metadata;
17
18type ReconstructResult<T> =
19    Result<VidReconstructOutput<T>, VidReconstructError<<T as NodeType>::SignatureKey>>;
20
21pub struct VidReconstructOutput<T: NodeType> {
22    pub view: ViewNumber,
23    pub epoch: EpochNumber,
24    pub payload_commitment: VidCommitment2,
25    pub payload: T::BlockPayload,
26    pub metadata: <T::BlockPayload as BlockPayload<T>>::Metadata,
27    pub tx_commitments: Vec<Commitment<T::Transaction>>,
28}
29
30/// Why a reconstruction attempt failed.
31#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
32pub enum VidReconstructErrorKind {
33    /// Unverifiable shares were weeded out; reconstruction retries once the
34    /// remaining shares cover the recovery threshold again.
35    #[error("awaiting more shares after weeding out unverifiable ones")]
36    AwaitingShares,
37    /// Every share verified yet the payload still does not re-commit: the
38    /// disperser committed to a non-codeword, so no subset can ever recover it.
39    #[error("unrecoverable: verified shares cannot decode to a payload matching the commitment")]
40    Unrecoverable,
41}
42
43/// A failed reconstruction attempt for one view and claimed commitment.
44#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
45#[error("VID reconstruction failed for view {view}: {kind}")]
46pub struct VidReconstructError<K> {
47    pub view: ViewNumber,
48    pub payload_commitment: VidCommitment2,
49    pub kind: VidReconstructErrorKind,
50    /// Voters whose shares failed verification against the commitment.
51    /// Provably bad and attributable: each share arrived in a message
52    /// signed by its voter.
53    pub bad_share_keys: Vec<K>,
54}
55
56#[derive(Default)]
57pub struct VidReconstructor<T: NodeType> {
58    /// Shares that arrived before their view's proposal, one per voter:
59    /// admitted (or dropped) once the proposal pins the view's commitment.
60    pending: BTreeMap<ViewNumber, BTreeMap<T::SignatureKey, VidDisperseShare2<T>>>,
61    /// One accumulator per view, created when its validated proposal
62    /// arrives: reconstruction needs the proposal's metadata, so only the
63    /// proposal's commitment is worth accumulating.
64    accumulators: BTreeMap<ViewNumber, VidShareAccumulator<T>>,
65    reconstructed: BTreeSet<ViewNumber>,
66    tasks: JoinSet<ReconstructResult<T>>,
67    calculations: BTreeMap<ViewNumber, AbortHandle>,
68}
69
70pub(crate) struct VidShareAccumulator<T: NodeType> {
71    /// The payload commitment claimed by the view's validated proposal.
72    payload_commitment: VidCommitment2,
73    metadata: Metadata<T>,
74    epoch: EpochNumber,
75    /// The VID erasure parameters the committee fixes for this view, used to
76    /// reject shares carrying a forged `common.param` (see [`Self::accept`]).
77    /// `None` if the committee could not be resolved; the param check is then
78    /// skipped, matching the previously unchecked path.
79    expected_param: Option<AvidmGf2Param>,
80    /// Common data pinned by the first admitted share; hash-bound to
81    /// `payload_commitment` (see [`Self::accept`]).
82    common: Option<AvidmGf2Common>,
83    /// Admitted shares by voter; their shard ranges are pairwise disjoint
84    /// (see [`Self::accept`]).
85    shares: BTreeMap<T::SignatureKey, AvidmGf2Share>,
86    /// Every voter whose share was admitted, including weeded ones: a voter
87    /// whose share failed verification doesn't get a second submission.
88    seen_keys: HashSet<T::SignatureKey>,
89    /// Set when a fully verified share set failed to decode to a payload
90    /// matching the commitment: the disperser is provably faulty and further
91    /// attempts are pointless.
92    exhausted: bool,
93}
94
95impl<T: NodeType> VidReconstructor<T> {
96    pub fn new() -> Self {
97        Self {
98            pending: BTreeMap::new(),
99            accumulators: BTreeMap::new(),
100            reconstructed: BTreeSet::new(),
101            tasks: JoinSet::new(),
102            calculations: BTreeMap::new(),
103        }
104    }
105
106    /// Pin `view` to its validated proposal's payload commitment and
107    /// metadata, and admit any shares that arrived before the proposal.
108    pub(crate) fn handle_proposal(
109        &mut self,
110        view: ViewNumber,
111        payload_commitment: VidCommitment2,
112        metadata: Metadata<T>,
113        epoch: EpochNumber,
114        expected_param: Option<AvidmGf2Param>,
115    ) {
116        if self.reconstructed.contains(&view) {
117            return;
118        }
119        let accumulator = match self.accumulators.entry(view) {
120            Entry::Occupied(existing) => {
121                // The first proposal wins: an equivocating leader cannot re-pin
122                // the view to another commitment.
123                if existing.get().payload_commitment != payload_commitment {
124                    warn!(%view, "conflicting proposal for a view pinned to another commitment");
125                }
126                return;
127            },
128            Entry::Vacant(slot) => slot.insert(VidShareAccumulator::new(
129                payload_commitment,
130                metadata,
131                epoch,
132                expected_param,
133            )),
134        };
135        for (sender, share) in self.pending.remove(&view).into_iter().flatten() {
136            accumulator.accept(view, sender, share);
137        }
138        self.try_reconstruct(view);
139    }
140
141    pub(crate) fn handle_vid_share(
142        &mut self,
143        sender: T::SignatureKey,
144        share: VidDisperseShare2<T>,
145    ) {
146        let view = share.view_number;
147        // A share carries the voter it belongs to; only the authenticated
148        // sender may contribute its own. This cheap check bounds each node to
149        // one slot and guards the pre-proposal `pending` window, where a share
150        // cannot yet be verified against a commitment.
151        if share.recipient_key != sender {
152            warn!(%view, ?sender, "VID share recipient key does not match its sender");
153            return;
154        }
155        if self.reconstructed.contains(&view) {
156            return;
157        }
158        let Some(accumulator) = self.accumulators.get_mut(&view) else {
159            // No validated proposal yet: hold the voter's share until
160            // `handle_proposal` pins the view's commitment.
161            self.pending
162                .entry(view)
163                .or_default()
164                .entry(sender)
165                .or_insert(share);
166            return;
167        };
168        accumulator.accept(view, sender, share);
169        self.try_reconstruct(view);
170    }
171
172    pub async fn next(&mut self) -> Option<ReconstructResult<T>> {
173        loop {
174            match self.tasks.join_next().await {
175                Some(Ok(Ok(out))) => {
176                    self.calculations.remove(&out.view);
177                    self.accumulators.remove(&out.view);
178                    self.reconstructed.insert(out.view);
179                    return Some(Ok(out));
180                },
181                Some(Ok(Err(err))) => {
182                    self.calculations.remove(&err.view);
183                    self.handle_failed_attempt(&err);
184                    return Some(Err(err));
185                },
186                Some(Err(_)) => continue,
187                None => return None,
188            }
189        }
190    }
191
192    /// Apply the outcome of a failed attempt: weed the bad shares out of the
193    /// accumulator, then either mark the payload as unrecoverable or retry
194    /// (`try_reconstruct` re-checks coverage, which shares that arrived while
195    /// the attempt ran may already restore).
196    fn handle_failed_attempt(&mut self, err: &VidReconstructError<T::SignatureKey>) {
197        let Some(accumulator) = self.accumulators.get_mut(&err.view) else {
198            return;
199        };
200        // Views are pinned to one commitment for their lifetime, so a
201        // finished attempt always matches; guard anyway so a future re-pin
202        // policy can't weed the wrong accumulator.
203        if accumulator.payload_commitment != err.payload_commitment {
204            return;
205        }
206        for key in &err.bad_share_keys {
207            accumulator.shares.remove(key);
208        }
209        match err.kind {
210            VidReconstructErrorKind::Unrecoverable => accumulator.exhausted = true,
211            VidReconstructErrorKind::AwaitingShares => self.try_reconstruct(err.view),
212        }
213    }
214
215    fn try_reconstruct(&mut self, view: ViewNumber) {
216        if self.calculations.contains_key(&view) {
217            return;
218        }
219        let Some(accumulator) = self.accumulators.get(&view) else {
220            return;
221        };
222        if accumulator.exhausted || !accumulator.has_enough_shares() {
223            return;
224        }
225        // Enough shares implies an admitted share, which pinned the common.
226        let Some(common) = accumulator.common.clone() else {
227            return;
228        };
229        let payload_commitment = accumulator.payload_commitment;
230        let metadata = accumulator.metadata.clone();
231        let shares: Vec<(T::SignatureKey, AvidmGf2Share)> = accumulator
232            .shares
233            .iter()
234            .map(|(key, share)| (key.clone(), share.clone()))
235            .collect();
236        let epoch = accumulator.epoch;
237        let task = self.tasks.spawn_blocking(move || {
238            reconstruct::<T>(view, epoch, payload_commitment, common, shares, metadata)
239        });
240        self.calculations.insert(view, task);
241    }
242
243    pub fn gc(&mut self, view_number: ViewNumber) {
244        let keep = self.calculations.split_off(&view_number);
245        for handle in self.calculations.values_mut() {
246            handle.abort();
247        }
248        self.calculations = keep;
249        self.pending = self.pending.split_off(&view_number);
250        self.accumulators = self.accumulators.split_off(&view_number);
251        self.reconstructed = self.reconstructed.split_off(&view_number);
252    }
253
254    /// Stop tracking `view`.
255    ///
256    /// Either because its payload was reconstructed (or obtained elsewhere)
257    /// or because it timed out and will never be decided: record it so
258    /// `handle_vid_share` ignores later shares, drop its accumulator and
259    /// pending shares, and abort any in-flight reconstruction task.
260    pub fn retire_view(&mut self, view: ViewNumber) {
261        self.reconstructed.insert(view);
262        self.pending.remove(&view);
263        self.accumulators.remove(&view);
264        if let Some(handle) = self.calculations.remove(&view) {
265            handle.abort();
266        }
267    }
268}
269
270impl<T: NodeType> VidShareAccumulator<T> {
271    fn new(
272        payload_commitment: VidCommitment2,
273        metadata: Metadata<T>,
274        epoch: EpochNumber,
275        expected_param: Option<AvidmGf2Param>,
276    ) -> Self {
277        Self {
278            payload_commitment,
279            metadata,
280            epoch,
281            expected_param,
282            common: None,
283            shares: BTreeMap::new(),
284            seen_keys: HashSet::new(),
285            exhausted: false,
286        }
287    }
288
289    /// Admit `share` from the authenticated `sender`, dropping it if it fails
290    /// any intake check. The non-overlapping path stays crypto-free; a
291    /// shard-range overlap is the only case that triggers per-share
292    /// verification, via [`Self::resolve_conflict`].
293    fn accept(&mut self, view: ViewNumber, sender: T::SignatureKey, share: VidDisperseShare2<T>) {
294        if self.exhausted {
295            return;
296        }
297        if share.payload_commitment != self.payload_commitment {
298            warn!(%view, ?sender, "VID share commitment differs from the proposal's");
299            return;
300        }
301        // The commitment binds a share's `ns_commits` but not its `param`, so a
302        // Byzantine voter can pair real `ns_commits` with a forged `param` (e.g.
303        // an inflated `recovery_threshold`). Pinning that common as the
304        // verification oracle would reject every honest share, so reject it now.
305        if let Some(expected) = &self.expected_param
306            && share.common.param != *expected
307        {
308            warn!(%view, ?sender, "VID share common param differs from the committee's");
309            return;
310        }
311        // The commitment hash-binds the common, so trust it as the verification
312        // oracle only after that check; later shares must carry the same common.
313        if let Some(common) = &self.common {
314            if share.common != *common {
315                warn!(%view, ?sender, "VID share common differs from the accumulator's");
316                return;
317            }
318        } else if AvidmGf2Scheme::is_consistent(&self.payload_commitment, &share.common) {
319            self.common = Some(share.common.clone());
320        } else {
321            warn!(%view, ?sender, "VID share common is inconsistent with its commitment");
322            return;
323        }
324        // A share whose namespaces disagree on the shard range is malformed.
325        let Some(range) = share.share.range() else {
326            warn!(%view, ?sender, "VID share has an inconsistent shard range");
327            return;
328        };
329        // An empty range contributes nothing; positions past the end of the
330        // encoded payload would inflate coverage without aiding decoding.
331        if range.is_empty() || range.end > share.common.param.total_weights {
332            warn!(%view, ?sender, ?range, "VID share has an empty or out-of-bounds shard range");
333            return;
334        }
335        if self.seen_keys.contains(&sender) {
336            return;
337        }
338        // Honest dispersal assigns disjoint ranges, so an overlap with an
339        // admitted share proves a squat; resolve it (needs verification) below.
340        let conflicts: Vec<T::SignatureKey> = self
341            .shares
342            .iter()
343            .filter(|(_, admitted)| {
344                admitted
345                    .range()
346                    .is_some_and(|covered| covered.start < range.end && range.start < covered.end)
347            })
348            .map(|(key, _)| key.clone())
349            .collect();
350        if conflicts.is_empty() {
351            self.seen_keys.insert(sender.clone());
352            self.shares.insert(sender, share.share);
353            return;
354        }
355        self.resolve_conflict(view, sender, share, conflicts);
356    }
357
358    /// Resolve a shard-range collision between the incoming `share` and the
359    /// already-admitted `conflicts`: verify each against the commitment-bound
360    /// common, evict those that fail, and admit the newcomer only if it
361    /// verifies and no surviving share still covers its range.
362    fn resolve_conflict(
363        &mut self,
364        view: ViewNumber,
365        sender: T::SignatureKey,
366        share: VidDisperseShare2<T>,
367        conflicts: Vec<T::SignatureKey>,
368    ) {
369        // A conflict implies a prior admission, which pinned the common.
370        let Some(common) = self.common.clone() else {
371            return;
372        };
373        // The sender has used its one slot regardless of the outcome.
374        self.seen_keys.insert(sender.clone());
375        let mut survivor = false;
376        for key in conflicts {
377            let verified = self
378                .shares
379                .get(&key)
380                .is_some_and(|admitted| share_verifies(&common, admitted));
381            if verified {
382                survivor = true;
383            } else {
384                warn!(%view, ?key, "evicting unverifiable VID share squatting a shard range");
385                self.shares.remove(&key);
386            }
387        }
388        // A verified share still covers the contested range: the newcomer would
389        // double-cover it, so drop it.
390        if survivor {
391            return;
392        }
393        if share_verifies(&common, &share.share) {
394            self.shares.insert(sender, share.share);
395        } else {
396            warn!(%view, ?sender, "dropping unverifiable VID share at intake conflict");
397        }
398    }
399
400    fn ranges(&self) -> impl Iterator<Item = &Range<usize>> {
401        // Admitted shares always have a consistent range (checked in `accept`).
402        self.shares.values().filter_map(AvidmGf2Share::range)
403    }
404
405    /// Number of shard positions covered by the admitted shares; exact
406    /// because their ranges are disjoint.
407    fn coverage(&self) -> usize {
408        self.ranges().map(ExactSizeIterator::len).sum()
409    }
410
411    fn has_enough_shares(&self) -> bool {
412        self.common
413            .as_ref()
414            .is_some_and(|common| self.coverage() >= common.param.recovery_threshold)
415    }
416}
417
418/// Decode the shares and accept the result only if it re-commits to
419/// `payload_commitment`. On failure, report the shares that fail
420/// verification against the commitment (each share is self-authenticating
421/// via its merkle proofs) so they can be weeded out. If every share
422/// verifies, the payload is unrecoverable: the shares cover the recovery
423/// threshold with disjoint ranges, so the disperser committed to a
424/// non-codeword and no share subset can ever succeed.
425fn reconstruct<T: NodeType>(
426    view: ViewNumber,
427    epoch: EpochNumber,
428    payload_commitment: VidCommitment2,
429    common: AvidmGf2Common,
430    shares: Vec<(T::SignatureKey, AvidmGf2Share)>,
431    metadata: Metadata<T>,
432) -> ReconstructResult<T> {
433    let (keys, shares): (Vec<_>, Vec<_>) = shares.into_iter().unzip();
434    if let Some(bytes) =
435        decode_and_recommit::<T>(view, &common, &shares, &payload_commitment, &metadata)
436    {
437        let payload = T::BlockPayload::from_bytes(&bytes, &metadata);
438        let tx_commitments = payload.transaction_commitments(&metadata);
439        let output = VidReconstructOutput {
440            view,
441            epoch,
442            payload_commitment,
443            payload,
444            metadata,
445            tx_commitments,
446        };
447        return Ok(output);
448    }
449
450    let bad_share_keys: Vec<_> = keys
451        .into_iter()
452        .zip(&shares)
453        .filter(|(_, share)| !share_verifies(&common, share))
454        .map(|(key, _)| key)
455        .collect();
456
457    let kind = if bad_share_keys.is_empty() {
458        warn!(
459            %view,
460            %payload_commitment,
461            "verified shares cannot decode to a payload matching the commitment"
462        );
463        VidReconstructErrorKind::Unrecoverable
464    } else {
465        warn!(
466            %view,
467            %payload_commitment,
468            ?bad_share_keys,
469            "weeded out VID shares that failed verification"
470        );
471        VidReconstructErrorKind::AwaitingShares
472    };
473    Err(VidReconstructError {
474        view,
475        payload_commitment,
476        kind,
477        bad_share_keys,
478    })
479}
480
481/// Decode `shares` and return the payload bytes only if they re-commit to
482/// `payload_commitment`. Recovery alone does not bind the decoded bytes
483/// to the commitment: a Byzantine disperser can commit to a non-codeword,
484/// and a bad share poisons the erasure decoding.
485fn decode_and_recommit<T: NodeType>(
486    view: ViewNumber,
487    common: &AvidmGf2Common,
488    shares: &[AvidmGf2Share],
489    payload_commitment: &VidCommitment2,
490    metadata: &Metadata<T>,
491) -> Option<Vec<u8>> {
492    let bytes = match AvidmGf2Scheme::recover(common, shares) {
493        Ok(bytes) => bytes,
494        Err(err) => {
495            warn!(%view, %err, "VID recovery failed");
496            return None;
497        },
498    };
499    let ns_table = parse_ns_table(bytes.len(), &metadata.encode());
500    match AvidmGf2Scheme::commit(&common.param, &bytes, ns_table) {
501        Ok((recomputed, _)) if recomputed == *payload_commitment => Some(bytes),
502        Ok((recomputed, _)) => {
503            warn!(
504                %view,
505                expected = %payload_commitment,
506                %recomputed,
507                "reconstructed payload does not match the payload commitment"
508            );
509            None
510        },
511        Err(err) => {
512            warn!(%view, %err, "failed to recommit reconstructed VID payload");
513            None
514        },
515    }
516}
517
518/// Whether `share` verifies against a `common` already known to be hash-bound
519/// to the commitment (a `verify_with_verified_common` success).
520fn share_verifies(common: &AvidmGf2Common, share: &AvidmGf2Share) -> bool {
521    matches!(
522        AvidmGf2Scheme::verify_share_with_verified_common(common, share),
523        Ok(Ok(()))
524    )
525}