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_types::{
8    data::{
9        EpochNumber, VidCommitment2, VidDisperseShare2, ViewNumber,
10        ns_table::{ns_table_payload_byte_len, parse_ns_table},
11    },
12    traits::{BlockPayload, block_contents::EncodeBytes, node_implementation::NodeType},
13    vid::avidm_gf2::{AvidmGf2Common, AvidmGf2Param, AvidmGf2Scheme, AvidmGf2Share},
14};
15use tokio::task::{AbortHandle, JoinSet};
16use tracing::{error, warn};
17
18use super::ns_lens_match_metadata;
19
20pub(crate) type Metadata<T> = <<T as NodeType>::BlockPayload as BlockPayload<T>>::Metadata;
21
22type ReconstructResult<T> =
23    Result<ObtainedPayload<T>, VidReconstructError<<T as NodeType>::SignatureKey>>;
24
25/// A block's payload in hand, with what the node needs to file it.
26///
27/// Produced by decoding a view's shares and, on the recovery path, by a
28/// payload a peer sent whole. Either way the bytes are bound to
29/// `payload_commitment` before this is built.
30pub struct ObtainedPayload<T: NodeType> {
31    pub view: ViewNumber,
32    pub epoch: EpochNumber,
33    pub payload_commitment: VidCommitment2,
34    pub payload: T::BlockPayload,
35    pub metadata: <T::BlockPayload as BlockPayload<T>>::Metadata,
36    pub tx_commitments: Vec<Commitment<T::Transaction>>,
37}
38
39/// Why a reconstruction attempt failed.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
41pub enum VidReconstructErrorKind {
42    /// Unverifiable shares were weeded out; reconstruction retries once the
43    /// remaining shares cover the recovery threshold again.
44    #[error("awaiting more shares after weeding out unverifiable ones")]
45    AwaitingShares,
46    /// Every share verified yet the payload still does not re-commit: the
47    /// disperser committed to a non-codeword, so no subset can ever recover it.
48    #[error("unrecoverable: verified shares cannot decode to a payload matching the commitment")]
49    Unrecoverable,
50}
51
52/// A failed reconstruction attempt for one view and claimed commitment.
53#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
54#[error("VID reconstruction failed for view {view}: {kind}")]
55pub struct VidReconstructError<K> {
56    pub view: ViewNumber,
57    pub payload_commitment: VidCommitment2,
58    pub kind: VidReconstructErrorKind,
59    /// Voters whose shares failed verification against the commitment.
60    /// Provably bad and attributable: each share arrived in a message
61    /// signed by its voter.
62    pub bad_share_keys: Vec<K>,
63}
64
65/// What vouches for the proposal a view is pinned to.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum PinnedBy {
68    /// The leader alone: the proposal validated and paired with this node's
69    /// share. An equivocating leader may have given the quorum another one.
70    Leader,
71    /// A certificate: the quorum voted for it, so the view decides on it if
72    /// it decides at all.
73    Certificate,
74}
75
76#[derive(Default)]
77pub struct VidReconstructor<T: NodeType> {
78    /// Shares held for a commitment no proposal has pinned their view to,
79    /// one per voter: they arrived before the proposal, or while the leader's
80    /// pin holds another commitment that a certificate may yet replace.
81    pending: BTreeMap<ViewNumber, BTreeMap<T::SignatureKey, VidDisperseShare2<T>>>,
82    /// One accumulator per view, created when a proposal pins it:
83    /// reconstruction needs the proposal's metadata, so only the pinned
84    /// commitment is worth accumulating.
85    accumulators: BTreeMap<ViewNumber, VidShareAccumulator<T>>,
86    reconstructed: BTreeSet<ViewNumber>,
87    tasks: JoinSet<ReconstructResult<T>>,
88    calculations: BTreeMap<ViewNumber, AbortHandle>,
89    tracer: Option<crate::leader_trace::LeaderTracerHandle>,
90}
91
92pub(crate) struct VidShareAccumulator<T: NodeType> {
93    /// The payload commitment claimed by the proposal the view is pinned to.
94    payload_commitment: VidCommitment2,
95    /// What vouches for that proposal; only a certificate's pin is final.
96    pinned_by: PinnedBy,
97    metadata: Metadata<T>,
98    epoch: EpochNumber,
99    /// The VID erasure parameters the committee fixes for this view, used to
100    /// reject shares carrying a forged `common.param` (see [`Self::accept`]).
101    /// `None` if the committee could not be resolved; the param check is then
102    /// skipped, matching the previously unchecked path.
103    expected_param: Option<AvidmGf2Param>,
104    /// Common data pinned by the first admitted share; hash-bound to
105    /// `payload_commitment` (see [`Self::accept`]).
106    common: Option<AvidmGf2Common>,
107    /// Admitted shares by voter; their shard ranges are pairwise disjoint
108    /// (see [`Self::accept`]).
109    shares: BTreeMap<T::SignatureKey, AvidmGf2Share>,
110    /// Every voter whose share was admitted, including weeded ones: a voter
111    /// whose share failed verification doesn't get a second submission.
112    seen_keys: HashSet<T::SignatureKey>,
113    /// Set when a fully verified share set failed to decode to a payload
114    /// matching the commitment: the disperser is provably faulty and further
115    /// attempts are pointless.
116    exhausted: bool,
117}
118
119impl<T: NodeType> VidReconstructor<T> {
120    pub fn new() -> Self {
121        Self {
122            pending: BTreeMap::new(),
123            accumulators: BTreeMap::new(),
124            reconstructed: BTreeSet::new(),
125            tasks: JoinSet::new(),
126            calculations: BTreeMap::new(),
127            tracer: None,
128        }
129    }
130
131    pub fn set_tracer(&mut self, tracer: Option<crate::leader_trace::LeaderTracerHandle>) {
132        self.tracer = tracer;
133    }
134
135    /// Pin `view` to a proposal's payload commitment and metadata, and admit
136    /// the held shares for that commitment.
137    ///
138    /// The first pin holds against later ones, except that a certificate's
139    /// replaces the leader's: an equivocating leader may have paired this
140    /// node with a proposal the quorum did not certify.
141    pub(crate) fn handle_proposal(
142        &mut self,
143        view: ViewNumber,
144        payload_commitment: VidCommitment2,
145        metadata: Metadata<T>,
146        epoch: EpochNumber,
147        expected_param: Option<AvidmGf2Param>,
148        pinned_by: PinnedBy,
149    ) {
150        if self.reconstructed.contains(&view) {
151            return;
152        }
153        let accumulator = match self.accumulators.entry(view) {
154            Entry::Occupied(mut existing) => {
155                let pinned = existing.get_mut();
156                if pinned.payload_commitment == payload_commitment {
157                    if pinned_by == PinnedBy::Certificate {
158                        pinned.pinned_by = PinnedBy::Certificate;
159                    }
160                    return;
161                }
162                if pinned_by == PinnedBy::Leader || pinned.pinned_by == PinnedBy::Certificate {
163                    warn!(%view, "conflicting proposal for a view pinned to another commitment");
164                    return;
165                }
166                warn!(%view, "leader equivocated; re-pinning to the certified proposal");
167                if let Some(task) = self.calculations.remove(&view) {
168                    task.abort();
169                }
170                existing.insert(VidShareAccumulator::new(
171                    payload_commitment,
172                    metadata,
173                    epoch,
174                    expected_param,
175                    pinned_by,
176                ));
177                existing.into_mut()
178            },
179            Entry::Vacant(slot) => slot.insert(VidShareAccumulator::new(
180                payload_commitment,
181                metadata,
182                epoch,
183                expected_param,
184                pinned_by,
185            )),
186        };
187        // Shares for other commitments stay held: a certificate may yet re-pin
188        // the view to one of them.
189        let held = self.pending.remove(&view).unwrap_or_default();
190        let (admitted, held): (BTreeMap<_, _>, BTreeMap<_, _>) = held
191            .into_iter()
192            .partition(|(_, share)| share.payload_commitment == payload_commitment);
193        if !held.is_empty() {
194            self.pending.insert(view, held);
195        }
196        for (sender, share) in admitted {
197            accumulator.accept(view, sender, share);
198        }
199        self.try_reconstruct(view);
200    }
201
202    pub(crate) fn handle_vid_share(
203        &mut self,
204        sender: T::SignatureKey,
205        share: VidDisperseShare2<T>,
206    ) {
207        let view = share.view_number;
208        // A share carries the voter it belongs to; only the authenticated
209        // sender may contribute its own. This cheap check bounds each node to
210        // one slot and guards the `pending` window, where a share cannot yet
211        // be verified against a commitment.
212        if share.recipient_key != sender {
213            warn!(%view, ?sender, "VID share recipient key does not match its sender");
214            return;
215        }
216        if self.reconstructed.contains(&view) {
217            return;
218        }
219        let Some(accumulator) = self.accumulators.get_mut(&view) else {
220            self.hold(view, sender, share);
221            return;
222        };
223        if share.payload_commitment == accumulator.payload_commitment {
224            accumulator.accept(view, sender, share);
225            self.try_reconstruct(view);
226            return;
227        }
228        match accumulator.pinned_by {
229            // A certificate may yet re-pin the view to the share's commitment.
230            PinnedBy::Leader => self.hold(view, sender, share),
231            PinnedBy::Certificate => {
232                warn!(%view, ?sender, "VID share commitment differs from the certified proposal's");
233            },
234        }
235    }
236
237    /// Hold a voter's share until a proposal pins the view to its commitment.
238    /// One slot per voter: the first share it sends for the view stays.
239    fn hold(&mut self, view: ViewNumber, sender: T::SignatureKey, share: VidDisperseShare2<T>) {
240        self.pending
241            .entry(view)
242            .or_default()
243            .entry(sender)
244            .or_insert(share);
245    }
246
247    pub async fn next(&mut self) -> Option<ReconstructResult<T>> {
248        loop {
249            match self.tasks.join_next().await? {
250                Ok(Ok(out)) => {
251                    if self.is_superseded(out.view, out.payload_commitment) {
252                        continue;
253                    }
254                    self.calculations.remove(&out.view);
255                    self.accumulators.remove(&out.view);
256                    self.reconstructed.insert(out.view);
257                    return Some(Ok(out));
258                },
259                Ok(Err(err)) => {
260                    if self.is_superseded(err.view, err.payload_commitment) {
261                        continue;
262                    }
263                    self.calculations.remove(&err.view);
264                    self.handle_failed_attempt(&err);
265                    return Some(Err(err));
266                },
267                Err(err) => {
268                    if err.is_panic() {
269                        error!(%err, "VID reconstruction task panicked");
270                    }
271                    self.calculations.retain(|_, task| task.id() != err.id());
272                },
273            }
274        }
275    }
276
277    /// Whether an attempt's result is for a pin a certificate has since
278    /// replaced. The attempt was aborted, but a blocking task that had already
279    /// started still runs to completion.
280    fn is_superseded(&self, view: ViewNumber, payload_commitment: VidCommitment2) -> bool {
281        self.accumulators
282            .get(&view)
283            .is_some_and(|accumulator| accumulator.payload_commitment != payload_commitment)
284    }
285
286    /// Apply the outcome of a failed attempt: weed the bad shares out of the
287    /// accumulator, then either mark the payload as unrecoverable or retry
288    /// (`try_reconstruct` re-checks coverage, which shares that arrived while
289    /// the attempt ran may already restore).
290    fn handle_failed_attempt(&mut self, err: &VidReconstructError<T::SignatureKey>) {
291        let Some(accumulator) = self.accumulators.get_mut(&err.view) else {
292            return;
293        };
294        for key in &err.bad_share_keys {
295            accumulator.shares.remove(key);
296        }
297        match err.kind {
298            VidReconstructErrorKind::Unrecoverable => accumulator.exhausted = true,
299            VidReconstructErrorKind::AwaitingShares => self.try_reconstruct(err.view),
300        }
301    }
302
303    fn try_reconstruct(&mut self, view: ViewNumber) {
304        if self.calculations.contains_key(&view) {
305            return;
306        }
307        let Some(accumulator) = self.accumulators.get(&view) else {
308            return;
309        };
310        if accumulator.exhausted || !accumulator.has_enough_shares() {
311            return;
312        }
313        // Enough shares implies an admitted share, which pinned the common.
314        let Some(common) = accumulator.common.clone() else {
315            return;
316        };
317        let payload_commitment = accumulator.payload_commitment;
318        let metadata = accumulator.metadata.clone();
319        let shares: Vec<(T::SignatureKey, AvidmGf2Share)> = accumulator
320            .shares
321            .iter()
322            .map(|(key, share)| (key.clone(), share.clone()))
323            .collect();
324        let epoch = accumulator.epoch;
325        crate::trace_leader_event!(
326            self.tracer,
327            view,
328            crate::leader_trace::LeaderEvent::ThresholdShareReachedVMinus1
329        );
330        let tracer = self.tracer.clone();
331        let task = self.tasks.spawn_blocking(move || {
332            crate::trace_leader_event!(
333                tracer,
334                view,
335                crate::leader_trace::LeaderEvent::RecoverVMinus1Start
336            );
337            let result = reconstruct::<T>(
338                view,
339                epoch,
340                payload_commitment,
341                common,
342                shares,
343                metadata,
344                tracer.clone(),
345            );
346            if result.is_ok() {
347                crate::trace_leader_event!(
348                    tracer,
349                    view,
350                    crate::leader_trace::LeaderEvent::RecoverVMinus1End
351                );
352            }
353            result
354        });
355        self.calculations.insert(view, task);
356    }
357
358    pub fn gc(&mut self, view_number: ViewNumber) {
359        let keep = self.calculations.split_off(&view_number);
360        for handle in self.calculations.values_mut() {
361            handle.abort();
362        }
363        self.calculations = keep;
364        self.pending = self.pending.split_off(&view_number);
365        self.accumulators = self.accumulators.split_off(&view_number);
366        self.reconstructed = self.reconstructed.split_off(&view_number);
367    }
368
369    /// Stop tracking `view`.
370    ///
371    /// Either because its payload was reconstructed (or obtained elsewhere)
372    /// or because it timed out and will never be decided: record it so
373    /// `handle_vid_share` ignores later shares, drop its accumulator and
374    /// pending shares, and abort any in-flight reconstruction task.
375    pub fn retire_view(&mut self, view: ViewNumber) {
376        self.reconstructed.insert(view);
377        self.pending.remove(&view);
378        self.accumulators.remove(&view);
379        if let Some(handle) = self.calculations.remove(&view) {
380            handle.abort();
381        }
382    }
383}
384
385impl<T: NodeType> VidShareAccumulator<T> {
386    fn new(
387        payload_commitment: VidCommitment2,
388        metadata: Metadata<T>,
389        epoch: EpochNumber,
390        expected_param: Option<AvidmGf2Param>,
391        pinned_by: PinnedBy,
392    ) -> Self {
393        Self {
394            payload_commitment,
395            pinned_by,
396            metadata,
397            epoch,
398            expected_param,
399            common: None,
400            shares: BTreeMap::new(),
401            seen_keys: HashSet::new(),
402            exhausted: false,
403        }
404    }
405
406    /// Admit `share`, which claims this accumulator's commitment, from the
407    /// authenticated `sender`, dropping it if it fails any intake check. The
408    /// non-overlapping path stays crypto-free; a shard-range overlap is the
409    /// only case that triggers per-share verification, via
410    /// [`Self::resolve_conflict`].
411    fn accept(&mut self, view: ViewNumber, sender: T::SignatureKey, share: VidDisperseShare2<T>) {
412        debug_assert_eq!(share.payload_commitment, self.payload_commitment);
413        if self.exhausted {
414            return;
415        }
416        // The commitment binds a share's `ns_commits` but not its `param`, so a
417        // Byzantine voter can pair real `ns_commits` with a forged `param` (e.g.
418        // an inflated `recovery_threshold`). Pinning that common as the
419        // verification oracle would reject every honest share, so reject it now.
420        if let Some(expected) = &self.expected_param
421            && share.common.param != *expected
422        {
423            warn!(%view, ?sender, "VID share common param differs from the committee's");
424            return;
425        }
426        // The commitment hash-binds the common's `ns_commits` but not its
427        // `ns_lens`, which a forwarded share could shuffle against the
428        // proposal's table. Trust a common as the verification oracle only
429        // after both checks; later shares must carry the same common.
430        if let Some(common) = &self.common {
431            if share.common != *common {
432                warn!(%view, ?sender, "VID share common differs from the accumulator's");
433                return;
434            }
435        } else if !AvidmGf2Scheme::is_consistent(&self.payload_commitment, &share.common) {
436            warn!(%view, ?sender, "VID share common is inconsistent with its commitment");
437            return;
438        } else if !ns_lens_match_metadata(&share.common, &self.metadata.encode()) {
439            warn!(
440                %view, ?sender,
441                "VID share namespace lengths disagree with the proposal's namespace table"
442            );
443            return;
444        } else {
445            self.common = Some(share.common.clone());
446        }
447        // A share whose namespaces disagree on the shard range is malformed.
448        let Some(range) = share.share.range() else {
449            warn!(%view, ?sender, "VID share has an inconsistent shard range");
450            return;
451        };
452        // An empty range contributes nothing; positions past the end of the
453        // encoded payload would inflate coverage without aiding decoding.
454        if range.is_empty() || range.end > share.common.param.total_weights {
455            warn!(%view, ?sender, ?range, "VID share has an empty or out-of-bounds shard range");
456            return;
457        }
458        if self.seen_keys.contains(&sender) {
459            return;
460        }
461        // Honest dispersal assigns disjoint ranges, so an overlap with an
462        // admitted share proves a squat; resolve it (needs verification) below.
463        let conflicts: Vec<T::SignatureKey> = self
464            .shares
465            .iter()
466            .filter(|(_, admitted)| {
467                admitted
468                    .range()
469                    .is_some_and(|covered| covered.start < range.end && range.start < covered.end)
470            })
471            .map(|(key, _)| key.clone())
472            .collect();
473        if conflicts.is_empty() {
474            self.seen_keys.insert(sender.clone());
475            self.shares.insert(sender, share.share);
476            return;
477        }
478        self.resolve_conflict(view, sender, share, conflicts);
479    }
480
481    /// Resolve a shard-range collision between the incoming `share` and the
482    /// already-admitted `conflicts`: verify each against the commitment-bound
483    /// common, evict those that fail, and admit the newcomer only if it
484    /// verifies and no surviving share still covers its range.
485    fn resolve_conflict(
486        &mut self,
487        view: ViewNumber,
488        sender: T::SignatureKey,
489        share: VidDisperseShare2<T>,
490        conflicts: Vec<T::SignatureKey>,
491    ) {
492        // A conflict implies a prior admission, which pinned the common.
493        let Some(common) = self.common.clone() else {
494            return;
495        };
496        // The sender has used its one slot regardless of the outcome.
497        self.seen_keys.insert(sender.clone());
498        let mut survivor = false;
499        for key in conflicts {
500            let verified = self
501                .shares
502                .get(&key)
503                .is_some_and(|admitted| share_verifies(&common, admitted));
504            if verified {
505                survivor = true;
506            } else {
507                warn!(%view, ?key, "evicting unverifiable VID share squatting a shard range");
508                self.shares.remove(&key);
509            }
510        }
511        // A verified share still covers the contested range: the newcomer would
512        // double-cover it, so drop it.
513        if survivor {
514            return;
515        }
516        if share_verifies(&common, &share.share) {
517            self.shares.insert(sender, share.share);
518        } else {
519            warn!(%view, ?sender, "dropping unverifiable VID share at intake conflict");
520        }
521    }
522
523    fn ranges(&self) -> impl Iterator<Item = &Range<usize>> {
524        // Admitted shares always have a consistent range (checked in `accept`).
525        self.shares.values().filter_map(AvidmGf2Share::range)
526    }
527
528    /// Number of shard positions covered by the admitted shares; exact
529    /// because their ranges are disjoint.
530    fn coverage(&self) -> usize {
531        self.ranges().map(ExactSizeIterator::len).sum()
532    }
533
534    fn has_enough_shares(&self) -> bool {
535        self.common
536            .as_ref()
537            .is_some_and(|common| self.coverage() >= common.param.recovery_threshold)
538    }
539}
540
541/// Decode the shares and accept the result only if it re-commits to
542/// `payload_commitment`. On failure, report the shares that fail
543/// verification against the commitment (each share is self-authenticating
544/// via its merkle proofs) so they can be weeded out. If every share
545/// verifies, the payload is unrecoverable: the shares cover the recovery
546/// threshold with disjoint ranges, so the disperser committed to a
547/// non-codeword and no share subset can ever succeed.
548fn reconstruct<T: NodeType>(
549    view: ViewNumber,
550    epoch: EpochNumber,
551    payload_commitment: VidCommitment2,
552    common: AvidmGf2Common,
553    shares: Vec<(T::SignatureKey, AvidmGf2Share)>,
554    metadata: Metadata<T>,
555    tracer: Option<crate::leader_trace::LeaderTracerHandle>,
556) -> ReconstructResult<T> {
557    let (keys, shares): (Vec<_>, Vec<_>) = shares.into_iter().unzip();
558    if let Some(bytes) =
559        decode_and_recommit::<T>(view, &common, &shares, &payload_commitment, &metadata)
560    {
561        crate::trace_leader_event!(
562            tracer,
563            view,
564            crate::leader_trace::LeaderEvent::RecoverVMinus1DecodeEnd
565        );
566        let payload = T::BlockPayload::from_bytes(&bytes, &metadata);
567        let tx_commitments = payload.transaction_commitments(&metadata);
568        let output = ObtainedPayload {
569            view,
570            epoch,
571            payload_commitment,
572            payload,
573            metadata,
574            tx_commitments,
575        };
576        return Ok(output);
577    }
578
579    let bad_share_keys: Vec<_> = keys
580        .into_iter()
581        .zip(&shares)
582        .filter(|(_, share)| !share_verifies(&common, share))
583        .map(|(key, _)| key)
584        .collect();
585
586    let kind = if bad_share_keys.is_empty() {
587        warn!(
588            %view,
589            %payload_commitment,
590            "verified shares cannot decode to a payload matching the commitment"
591        );
592        VidReconstructErrorKind::Unrecoverable
593    } else {
594        warn!(
595            %view,
596            %payload_commitment,
597            ?bad_share_keys,
598            "weeded out VID shares that failed verification"
599        );
600        VidReconstructErrorKind::AwaitingShares
601    };
602    Err(VidReconstructError {
603        view,
604        payload_commitment,
605        kind,
606        bad_share_keys,
607    })
608}
609
610/// Decode `shares` and return the payload bytes only if they re-commit to
611/// `payload_commitment`. Recovery alone does not bind the decoded bytes
612/// to the commitment: a Byzantine disperser can commit to a non-codeword,
613/// and a bad share poisons the erasure decoding.
614fn decode_and_recommit<T: NodeType>(
615    view: ViewNumber,
616    common: &AvidmGf2Common,
617    shares: &[AvidmGf2Share],
618    payload_commitment: &VidCommitment2,
619    metadata: &Metadata<T>,
620) -> Option<Vec<u8>> {
621    let bytes = match AvidmGf2Scheme::recover(common, shares) {
622        Ok(bytes) => bytes,
623        Err(err) => {
624            warn!(%view, %err, "VID recovery failed");
625            return None;
626        },
627    };
628    matches_commitment(
629        view,
630        &common.param,
631        &metadata.encode(),
632        &bytes,
633        payload_commitment,
634    )
635    .then_some(bytes)
636}
637
638/// Whether `payload` is what `payload_commitment` commits to, sliced by the
639/// header's namespace table `ns_table`.
640///
641/// The commitment covers each namespace's bytes but not the table, and
642/// `parse_ns_table` substitutes one namespace spanning the payload for a table
643/// sized for another payload. For a one-namespace block that substitute is the
644/// honest table, so a header claiming a shorter or longer payload would
645/// recommit to the same bytes and decide with a table that misdescribes them.
646/// Refuse such a table instead: it must be sized for `payload`, or be no
647/// table at all, which the disperser also reads as one namespace.
648pub(crate) fn matches_commitment(
649    view: ViewNumber,
650    param: &AvidmGf2Param,
651    ns_table: &[u8],
652    payload: &[u8],
653    payload_commitment: &VidCommitment2,
654) -> bool {
655    if let Some(claimed) = ns_table_payload_byte_len(ns_table)
656        && claimed != payload.len()
657    {
658        warn!(
659            %view,
660            claimed,
661            actual = payload.len(),
662            "namespace table is sized for another payload"
663        );
664        return false;
665    }
666    let ns_table = parse_ns_table(payload.len(), ns_table);
667    match AvidmGf2Scheme::commit(param, payload, ns_table) {
668        Ok((recomputed, _)) if recomputed == *payload_commitment => true,
669        Ok((recomputed, _)) => {
670            warn!(
671                %view,
672                expected = %payload_commitment,
673                %recomputed,
674                "payload does not match the payload commitment"
675            );
676            false
677        },
678        Err(err) => {
679            warn!(%view, %err, "failed to recommit VID payload");
680            false
681        },
682    }
683}
684
685/// Whether `share` verifies against a `common` already known to be hash-bound
686/// to the commitment (a `verify_with_verified_common` success).
687fn share_verifies(common: &AvidmGf2Common, share: &AvidmGf2Share) -> bool {
688    matches!(
689        AvidmGf2Scheme::verify_share_with_verified_common(common, share),
690        Ok(Ok(()))
691    )
692}